Simstim

Houdini

Stage in, stage out: Houdini's Solaris treats every node as a function over a USD scene

In Houdini's Solaris, every LOP node takes a composed USD stage and returns one, and its edits land in one in-memory layer at the top. A 31-line usd-core model of that contract, the Python LOP that does it for real, the same network checked in Houdini 22, and why a Layer Break decides what gets saved.

· 10 min read · houdini · houdini beyond vex · usd · python · pipeline

Real Houdini 22 output on a dark code panel, listing each LOP node's layer count, layers above a break and the visibility of scratch_ball, then the over that the USD ROP saved, which hides scratch_ball. Beside it, Karma renders of the same network's stage at sphere2, a blue and an orange sphere, and at pythonscript1, where only the blue sphere remains.

TL;DR Houdini's Solaris context builds scenes in OpenUSD, and every node in it behaves like a function: a composed scene goes in, a new composed scene comes out, and the node's edits are stacked on top as a thin layer instead of rewriting anything underneath. We model that contract in 31 lines of plain Python with pip install usd-core, show the Python LOP that does the same job inside Houdini, check the model against the real network in Houdini 22, and look at the one node, Layer Break, that decides which edits end up in the file you save.

Solaris in 60 seconds

Houdini is organized into contexts, each a graph of nodes that operate on one kind of data. SOPs work on geometry (points, primitives, attributes). DOPs run simulations. TOPs schedule tasks. LOPs (SideFX spells it out as "lighting operators"), the nodes of the context SideFX calls Solaris, work on a USD stage: Pixar's Universal Scene Description, the open scene format that Houdini, Blender, Unreal and Maya all read.

The USD vocabulary you need is short. A layer is one file (or in-memory buffer) of opinions: partial edits like "this sphere's radius is 2". A stage is what you get when you stack layers and resolve them, strongest opinion first. A prim is a node in the scene tree, addressed by a path like /World/ball. If you have read our post USD layers are git for scene data, this is that idea with a node graph wrapped around it.

Every LOP is a function from stage to stage

The SideFX docs put the contract plainly: the output of every LOP node is a fully composed USD stage. A node with an input takes the stage it is handed, makes "a new stage with modified copies of the prims it needs to change", and hands that on. Select any node in the network and the Scene Graph Tree pane shows the whole scene as it exists at that point, the way you can inspect any intermediate value in a pipeline of pure functions.

Where do the edits go? Not into the files you loaded. The stage's root layer holds nothing but a list of sublayers, and edits usually go into the strongest in-memory layer in that list, which Solaris calls the active layer. If the strongest layer is a file on disk, the next node that edits something creates a fresh anonymous layer above it to hold the change. Loaded files are never modified.

That makes a LOP network look a lot like a persistent data structure, or a functional program with a single accumulator. Each node returns a new value, the old values stay valid, and the "diff" each node contributes is an explicit, inspectable object.

The contract in 31 lines of usd-core

You do not need Houdini to see this work. The script below models a three-node network with nothing but USD's own library: an import node that defines two spheres, a Layer Break, and a "Python LOP" that hides anything whose name starts with scratch_. Save it as toy_lops.py, run pip install usd-core, then python toy_lops.py.

from pxr import Usd, Sdf, UsdGeom   # pip install usd-core

def compose(layers):                 # a "net" is a layer list, strongest first
    root = Sdf.Layer.CreateAnonymous()          # root layer: sublayers only
    root.subLayerPaths = [l.identifier for l in layers]
    return Usd.Stage.Open(root)

def edit(net, fn, frozen=()):        # one LOP node: stage in, new stage out
    active = Sdf.Layer.CreateAnonymous()
    if net and net[0] not in frozen:
        active.TransferContent(net[0]); net = net[1:]   # copy, never mutate
    stage = compose([active] + net)
    stage.SetEditTarget(active)                 # Houdini does this for you
    fn(stage)
    return [active] + net

def hide_scratch(stage):             # the body of a Python LOP, roughly
    for prim in stage.Traverse():
        if prim.GetName().startswith("scratch_"):
            UsdGeom.Imageable(prim).MakeInvisible()

a = edit([], lambda s: [UsdGeom.Sphere.Define(s, p) for p in ("/World/ball", "/World/scratch_ball")])
b = [Sdf.Layer.CreateAnonymous()] + a    # Layer Break: fresh active layer...
frozen = set(a)                          # ...and everything below is marked
c = edit(b, hide_scratch, frozen)

for name, net in [("a", a), ("c", c)]:
    stage = compose(net)                 # keep a reference, or prims expire
    vis = stage.GetAttributeAtPath("/World/scratch_ball.visibility").Get()
    print(name, "layers:", len(net), "scratch_ball visibility:", vis)
print([l for l in c if l not in frozen][0].ExportToString())  # what gets saved

With usd-core 26.11 on Python 3.12 it prints this, and Houdini 22's hython, with its own USD 26.05, prints the same:

a layers: 1 scratch_ball visibility: inherited
c layers: 2 scratch_ball visibility: invisible
#usda 1.0

over "World"
{
    over "scratch_ball"
    {
        token visibility = "invisible"
    }
}

In node a's stage, scratch_ball still has its default visibility, inherited, after c has cooked, so it still shows there. Node c sees both spheres, because the layer from before the break still composes underneath. But the only layer that would be written out is the thin one that holds nothing but overs, USD's keyword for "if this prim exists in a weaker layer, change these fields".

Our toy copies the active layer on every edit to keep things obviously pure. Houdini is smarter about memory. The hou.LopNode docs say many LOP nodes can share a single USD stage, and in our Houdini 22 test every node of the network below returned the same stage object, built from the same layer objects. What changed was the contents of those layers, depending on which node we had asked last. The semantics you observe are still stage in, stage out, as long as you ask the node for its stage each time.

The real Python LOP

Inside Houdini, drop a Python Script LOP after your import node and give it this body. It is the same hide_scratch function, with Houdini handing you the stage instead of our edit() wrapper.

from pxr import UsdGeom

node = hou.pwd()
prefix = "scratch_"                  # read parms and upstream data first
stage = node.editableStage()         # composed input, edit target = active layer
for prim in stage.Traverse():
    if prim.GetName().startswith(prefix):
        UsdGeom.Imageable(prim).MakeInvisible()

Three rules from the node's documentation are worth knowing before you write more than this. Don't change the edit target, because Houdini has already pointed it at the active layer. Never author into the root layer, and use hou.LopNode.addSubLayer if you want to add a sublayer. And either call editableStage() or editableLayer() in one script, not both: after editableLayer(), a call to editableStage() returns None.

Checking the model in Houdini

We built the toy's network in Houdini 22.0.368: two Sphere LOPs with their Primitive Path set to /World/ball and /World/scratch_ball, a Layer Break, the Python Script LOP from the previous section, and a USD ROP, all with their default node names. This loop, pasted into the Python Shell (Windows > Python Shell), prints each node's layer count, how many of its layers sit above a Layer Break, and the visibility of scratch_ball:

for name in ("sphere1", "sphere2", "layerbreak1", "pythonscript1"):
    node = hou.node("/stage/" + name)
    vis = node.stage().GetAttributeAtPath("/World/scratch_ball.visibility")
    print(f"{name:14} layers: {node.sourceLayerCount()}  above break: "
          f"{len(node.layersAboveLayerBreak())}  visibility: {vis.Get() if vis else None}")
sphere1        layers: 1  above break: 0  visibility: None
sphere2        layers: 1  above break: 0  visibility: inherited
layerbreak1    layers: 1  above break: 1  visibility: inherited
pythonscript1  layers: 2  above break: 1  visibility: invisible

That is the toy's answer. Both Sphere LOPs write into one layer, the Python LOP's edit lands in a second one, and only its stage hides the ball. One detail differs: activeLayer() on the Layer Break node itself returns None, so Houdini creates the fresh layer when the next node writes something, not at the break.

The file the USD ROP saves is the toy's over, plus some layer metadata:

#usda 1.0
(
    endTimeCode = 1
    framesPerSecond = 24
    metersPerUnit = 1
    startTimeCode = 1
    timeCodesPerSecond = 24
    upAxis = "Y"
)

over "World"
{
    over "scratch_ball"
    {
        token visibility = "invisible"
    }
}

What trips you up, and what clicks

  • Opening the editable layer as a stage shows only that layer. Usd.Stage.Open(node.editableLayer()) builds a brand-new stage from the active layer alone. Right after a Layer Break, that is an empty scene. Without a break you see whatever the nodes above wrote into the same layer, but never anything from the layers underneath it. Use editableStage() when you need to query the scene and author into it. If you also want the layer, take it from stage.GetEditTarget().GetLayer(): in our test it was the node's active layer.
  • Holding on to a stage from outside the network. hou.node("/stage/pythonscript1").stage() from a shelf tool returns that node's output, but because nodes may share one stage, the docs warn that keeping it around past one function call can give unexpected results. In our test, a stage fetched from sphere2 reported scratch_ball as inherited. Once the Python LOP below it had cooked, the same Python object reported invisible. Read what you need, then let it go.
  • Editing a stage from a shelf tool. Outside a Python LOP, editableStage() and editableLayer() return None, and the layers of the stage that stage() hands you refuse edits: DefinePrim fails with "Layer ... is not editable". The stage's session layer did take a change in our test, but the change was gone as soon as a parameter upstream changed. If an edit should persist, it belongs in a node.
  • Counting sublayers by hand. In our network the root layer listed four sublayers at every node, whether the node's stage had one real layer or two: Houdini pads its shared stage with empty placeholder layers. sourceLayerCount() and sourceLayer() give you the layers a node's stage is actually built from.
  • "Why is my saved file missing the geometry?" Layer Break is doing its job. The docs say it has no effect on composition, but when the USD ROP writes layers out, everything before the break is thrown away. Their example is a lighting department that loads the set dressing department's file, adds a Layer Break, deactivates a scratch light and adds its own lights: the saved file holds only those edits, not the set. hou.LopNode.layersAboveLayerBreak() lists the layers that will be dropped, and the USD ROP's Strip Layers Above Layer Breaks toggle, on by default, is the switch. With it off, our ROP wrote both spheres in full.
  • A Merge after a Layer Break can drop the scene early. To render this post's cover we merged the Python LOP's output with a separate camera-and-lights chain. With the Merge LOP's default style, Simple Merge, the merged stage had no spheres at all, because the merge styles that flatten layers remove the ones from above a Layer Break. Setting Merge Style to Separate Layers kept them.
  • What clicks: once you read each node as "returns a stage plus one more thin layer", the Scene Graph Layers pane stops being mysterious. It is the call stack of your scene.

Try it yourself

  • Run toy_lops.py with usd-core, then remove the Layer Break: replace the c = ... line with c = edit(a, hide_scratch); frozen = set(), and watch the saved output grow from a single override to both full sphere definitions.
  • In Houdini Apprentice (free for non-commercial use), put down two Sphere LOPs and set their Primitive Path to /World/ball and /World/scratch_ball (give the second a Translate X of 2.5 so they don't overlap). Then add a Layer Break and a Python Script LOP with the code above. Select each node in turn with the Scene Graph Layers pane open, or run the loop above in the Python Shell.
  • Add a USD ROP after the Python LOP, set its Output File, click Save to Disk, and open the file in a text editor: it should match the file above. Then turn off Strip Layers Above Layer Breaks and save again.

Sources