Handling FBX I/O in SOP Context
Last updated on: 7 March, 2020
Introduction
Surface operators (SOPs) do not recognize the concept of an object. They store information about geometry and how it changes in space and time within local transformation matrix, whereas the presence of objects (and their hierarchy) in FBX files is common.
If an FBX scene is imported into SOP context, its object hierarchy will be flattened. If an FBX is exported from SOPs, then by default every primitive group will be merged into a single object. In both cases no primitive groups will be created.
This post describes several possible solutions to separating geometry imported from FBX back into primitive groups, and how to convert primitive groups into FBX objects to construct a hierarchy.
Importing FBX in SOP context
When an FBX scene is imported to SOP context via File SOP, at least two scenarios may happen:
- If imported FBX file was generated by an external program, object names will usually be put into a primitive attribute called
name
. This also applies to Houdini-exported assets from object level, and to FBX files exported with FBX Output ROP SOP which had Build Hierarchy from Path Attribute flag set. - If imported FBX file was exported from Houdini’s SOP context and without an option to build scene hierarchy from an attribute, File SOP will create one integer primitive attribute for each object name it finds in the file. Those attributes will have the same names as objects that those primitives belong to, and will have their values set to
0
or1
. The program will also generate thename
attribute as described above, but it will contain the name of a SOP connected to output ROP at the time of export, and as such, it is not particularly useful.
FBX exported from other software
Dealing with objects imported from 3rd party programs is very straightforward as we only need to create a proper primitive group for each primitive, and name that group after the value of primitive’s name
attribute. The solution is pretty much a one-liner that we input into
Attribute Wrangle SOP running over primitives:
setprimgroup(0, @name, @primnum, 1, "set");
To keep things clean, we can then get rid of the name
primitive attribute. Otherwise we would need to keep it in sync with primitive groups if we ever want to export the FBX out of Houdini. We can always regenerate this attribute with a simple VEX code (more on that later).
FBX exported from Houdini (without built hierarchy)
This case is slightly more, but not overly, complicated. File SOP “places” each object name in a separate primitive attribute and this attribute is assigned a value of 1
or 0
, depending on whether the primitive was part of a given object or not. Primitive attribute name
is also there, but for some reason it is given a string value which is a name of the SOP connected to FBX Output ROP SOP at the time of exporting.
We start by removing unnecessary primitive attributes and leaving only those, which we know that hold object names (note, that this might be a problem if there are lots of objects in FBX file and they all have random names). If there were no extra primitive attributes present at the time of exporting, usually it’s enough to remove name
and shop_materialpath
. This leaves us with a clean primitiveattributes
detail intrinsic, which is basically a list of all our objects.
We need to iterate through the list and compare it with a value of a corresponding primitive attribute. If it’s 1
, we add the primitive to the current group. If it’s 0
, we do nothing:
string primgroups[] = detailintrinsic(0, "primitiveattributes");
foreach(string primgroup; primgroups){
if(primattrib(0, primgroup, @primnum, 1) == 1)
setprimgroup(0, primgroup, @primnum, 1);
}
At this point primitive attributes that we have just processed are no longer necessary, so we can remove them to keep our geometry clean.
Exporting primitive groups as FBX objects
Normally if we tried to export an FBX via FBX Output ROP SOP and import it back to Houdini or an external DCC program, we would end up with just one object, which isn’t particularly useful. We can fix this the easy or hard (but more fun) way.
The easy and recommended way
FBX Output ROP SOP has this nice parameter called Build Hierarchy From Path Attribute which, if enabled, allows us to construct object hierarchy inside an FBX file, based off a primitive attribute (the attribute defaults to path
, but it can be anything as long as it’s a string).
To create path
primitive attribute from primitive groups, we can use Attribute Wrangle SOP:
string primgroups[] = detailintrinsic(0, "primitivegroups");
foreach(string primgroup; primgroups){
if(inprimgroup(0, primgroup, @primnum) == 1)
s@path = primgroup;
}
What’s interesting, is that we can also easily create parent relationships between our objects. Let’s drop Pig geometry into the scene, pipe a wrangle with the code listed above and, after both nodes, drop another attribute wrangle where we will change pig’s eyes
to be parented under its head
:
if(s@path == "eyes")
@path = "head/eyes"
Then, export it with FBX Output ROP SOP, remembering to construct the hierarchy from path
. After importing the file to Blender, we will see the correct object hierarchy:

Blender. “Eyes” object parented under “head”.
The fun, but convoluted way
This solution is a lot more complicated, but in theory opens a way to manipulating object pivots before the export (in a way). The idea is to create an HDA, which will update a nested object network either on each cook, or on demand. We will take advantage of the Python SOP to divide existing primitive groups into objects in a nested and editable object network. Objects from this network will then be saved from ROP Network Manager also nested in the asset.
Let’s start by creating a Subnetwork SOP and turn it into an HDA. Our asset will only have one mandatory input and no outputs, so make appropriate changes in our asset’s tab.
Dive into our asset and create a Python SOP. Connect its first input to the only input of the asset. Also, at the same level create an Object Network Manager and the ROP Network. Set the latter as editable in . Inside the RopNet, create a Filmbox FBX ROP.
We have just set up the skeleton of our asset. Now, let’s deal with the juicy meat.
Open Save to Disk (execute)
, to the list of existing parameters. Add a new button parameter template just above Controls... (renderdialog)
and set its Horizontally Join to Next Parameter interface option to true
. This button will use a custom callback function and will replace Filmbox FBX ROP’s Save to Disk (execute)
button, because we will need to perform a couple of actions before actually exporting the geometry. Set the button’s callback script to:
kwargs["node"].hdaModule().export_fbx()
We don’t have this function in our HDA module yet, but we will create it soon enough.
Dive into our Python SOP as it’s time to write some code. First, let’s simplify some things by creating references to our nodes and networks, so we can use variables to shorten our lines and avoid nested expressions:
node = hou.pwd()
geo = node.geometry()
objnode = hou.node(node.parent().path() + '/objnet1')
ropnode = hou.node(node.parent().path() + '/ropnet1/filmboxfbx1')
Our nested Object Network will be updated on each cook of the Python SOP, so if the link between our HDA and its input is severed, we want this network to be wiped clean. Let’s delete all objects from this network by default:
objnode_children = objnode.children()
objnode.deleteItems(objnode_children, True)
With this done, we can start working on the main loop, which will take any primitive groups from the Python SOP, and basing on them create appropriate objects in the Object Network. We can do it by iterating through each primitive group existing in our hou.Geometry
instance, and for each one of those primitive groups, we will create a hou.ObjNode
containing a single
Object Merge SOP with appropriate relative object path and primitive group references:
for prim_group in geo.primGroups():
prim_group_name = prim_group.name()
object = objnode.createNode('geo')
object.setName(prim_group_name)
obj_import = object.createNode('object_merge')
obj_import.parm('objpath1').set('../../../python1')
obj_import.parm('group1').set(prim_group_name)
And that’s it for this node, maybe except of telling Houdini to lay all of those object nodes in a nicer fashion:
objnode.layoutChildren()
So, the final code of our Python SOP looks like this:
node = hou.pwd()
geo = node.geometry()
objnode = hou.node(node.parent().path() + '/objnet1')
ropnode = hou.node(node.parent().path() + '/ropnet1/filmboxfbx1')
objnode_children = objnode.children()
objnode.deleteItems(objnode_children, True)
## Iterate through PrimGroups and create appropriate ObjNodes
for prim_group in geo.primGroups():
prim_group_name = prim_group.name()
object = objnode.createNode('geo')
object.setName(prim_group_name)
obj_import = object.createNode('object_merge')
obj_import.parm('objpath1').set('../../../python1')
obj_import.parm('group1').set(prim_group_name)
objnode.layoutChildren()
Our work on this asset is almost done. Our object network changes its contents on each cook, but it still can’t export anything, so let’s deal with this problem now. Simply adding all of our objects from the Object Network to our HDA’s Export (startnode)
parameter will not work, as this would only the first listed object. We need to use a bundle that will include children of our object network. Its routines could be implemented in the Python SOP, but because this node cooks on each upstream update, it would very quickly clutter Houdini with multiple bundles of different names. In order to keep it tidy and keep track of the bundle, we would have to either give it a static name, or store the bundle as a parameter of our asset. I find both solutions inelegant and think that it’s probably best to create a bundle only for the time of exporting. Remember that export button callback that we created before? We’re going to take advantage of it now.
Open export_fbx
function that will do the following:
- create a temporary bundle,
- add all children objects of our nested object network to the bundle,
- add the bundle to Filmbox FBX ROP Export parameter,
- press ROP’s execute button,
- clean the Export parameter,
- remove the bundle from existence.
def export_fbx():
node = hou.pwd()
obj_net = hou.node(node.path() + '/objnet1')
bundle = hou.addNodeBundle()
for child in obj_net.children():
bundle.addNode(child)
node.parm('startnode').set('@' + bundle.name())
node.parm('execute').pressButton()
node.parm('startnode').set('')
bundle.destroy()
And that’s all. As an extra step, we could add some cosmetic tweaks, like setting:
- Disable When parameter option of Start/End/Inc (
f
), - Export Deforms as Vertex Caches (
deformsasvcs
), - Export Animation Clips (Takes) (
exportclips
)
to { trange == off }
in order to match the behavior of Filmbox FBX ROP. Anyway, we should now be able to export our primitive groups as FBX objects.
Wait, what about setting object pivots that you mentioned earlier?
We can do it, but it actually boils down to setting object translation and compensating it with SOP level transforms rather than setting the actual object pivot. As far as I know (and I might be mistaken), object pivots cannot be stored in an FBX file.
The first thing that we need to do is to move pivot of each object to a desired place. If we only need to center the pivot on geometry bounding box, we can take advantage of Move Pivot to Center SHELF. Otherwise, we need to do it manually.
The second step is to copy pivot translate vector to Translate parameter of the object and reset pivot translation to its default (0, 0, 0). At this point our geometry will be offset from its original position, and we need to negate the translation on SOP level. To do this, we need to copy object’s t
parameter, dive into SOP network, drop in a new
Transform SOP and paste relative reference to the object level’s t
vector.
Now we can export the network and import it somewhere else. Huzzah! Our pivots are no longer bound to world’s origin:

Pig head with a custom pivot position. Exported from Houdini and loaded in Blender.
It’s most likely possible to do the same for object’s pivot rotation, but because this whole convoluted method is outside my pipeline, and I was experimenting with it just for mere fun — I didn’t try fiddling with rotations.
Also, it probably should be obvious by now, but we will lose custom pivots if anything upstream is changed, because whenever our asset recooks, a brand-new object network will be generated from scratch. The asset would have to be modified to prevent this from happening.