Search
  • Andy Nicholas

Customising Houdini Nodes [Part 3]: Modifying The UI

Updated: Jan 20

Let's summarise what we've covered so far (see Part 1 and Part 2).

  • Our aim is to customise some of the built-in nodes (e.g. Mantra ROP), to add new parameters for things like render farm submission. We may want to customise existing parameters too.

  • In part 1 we looked at the pros and cons of using HDAs, and decided not to use them for this situation. Instead, we're going to customise each node instance using a Python script.

  • In part 2 we looked at all the different ways that we could potentially get Houdini to call our parameter-customising Python script automatically, and we saw how we might allow the user to manually trigger it too.

So first, let's have a look at how we use Houdini's Python API to add parameters to a node.


Creating Parameters


Using Python gives the same result as if we were to go via the "Edit Parameter Interface..." gear menu on the Parameter Pane. Remember, this only changes the UI for that particular node instance. It's not the same as going to "Type Properties" and changing the HDA definition.


First up, we need an introduction to the main Python object that is used to store and manipulate the node UI.


The ParmTemplateGroup Object


Each node in Houdini stores its own user interface in a Python object type called a ParmTemplateGroup (I'll be calling it the PTG for short). Assuming we have a Mantra ROP node located in our scene in "/out", we can get at it like this:

node = hou.node('/out/mantra1')
ptg = node.parmTemplateGroup()

The PTG stores all the folders and parameters for the node. You can add new parameters to this object or modify existing ones already stored within to make changes to the UI.


Whenever you get a ParmTemplateGroup object from a node, it's important to realise that you are always getting a new copy. Not a live representation of the node's UI. Making changes to this object will have no immediate affect on the node. To apply your changes to the node, you always need to make a call to the "setParmTemplateGroup()" method of the node like this:

node.setParmTemplateGroup(ptg)

However be warned! Updating the PTG for a node can be a relatively slow operation. Ideally, you should perform all your UI changes on the same PTG object and only call "setParmTemplateGroup()" once.


Adding a New Parameter


To add a new parameter, we need to choose what type we want to add. There are 12 different types (derived from the "hou.ParmTemplate" class) that you can use, but the most commonly used ones are:

hou.buttonParmTemplate
hou.toggleParmTemplate
hou.intParmTemplate
hou.floatParmTemplate
hou.stringParmTemplate

You can see a complete list of available parameter types at the top of the documentation page for the hou.ParmTemplate class.


To create a new float parameter, you would do something like this:

float_parm = hou.FloatParmTemplate("new_float_parm", 
                                   "New Float Parm", 
                                   1)

The "1" at the end specifies the number of components to the float parameter. If you wanted a floating point parameter with three values (e.g. an XYZ position), you'd provide "3" as the value. There are many other optional arguments you can supply when creating parameters, check out the documentation for the FloatParmTemplate to see what others are available.


Once your parameter has been created you can add it to the ParmTemplateGroup:

ptg.append(float_parm)

And then apply the modified PTG to the node to update the interface:

node.setParmTemplateGroup(ptg)

If you scroll all the way down the parameter interface for the node, you should see that you have a new parameter at the bottom that looks something like this:


Adding a Folder


Adding a folder is very similar and is represented by the "hou.FolderParmTemplate" class. Let's add a new folder and put another new Float parm inside.

node = hou.node('/out/mantra1')
ptg = node.parmTemplateGroup()
folder_parm = hou.FolderParmTemplate(
                  "my_folder", "New Folder", 
                  folder_type=hou.folderType.Tabs)

float_parm_2 = hou.FloatParmTemplate("new_float_parm_2",
                                     "New Float Parm 2", 
                                     1)

folder_parm.addParmTemplate(float_parm_2)
ptg.append(folder_parm)
node.setParmTemplateGroup(ptg)

Folders store their parameters in a tuple (a read-only list) which you can access via the "parmTemplates()" method:

>>> print folder_parm.parmTemplates()
  (<hou.FloatParmTemplate name='new_float_parm_2' 
   label='New Float Parm 2' length=1
   naming_scheme=XYZW look=Regular 
   default_value=(0,) tags={}>,)

If you scroll down to the bottom of the Parameter pane again, you'll see you've added this to the interface:

If we wanted, we could have added the new float parameter inside the folder when we created it, by giving it a tuple/list of ParmTemplates as the third argument:

folder_parm = hou.FolderParmTemplate(
                  "my_folder", "New Folder",
                  (float_parm_2,), 
                  hou.folderType.Tabs)

We no longer have to supply the folder type as a keyword argument, as these parameters now appear sequentially.


Inserting a Parameter


Let's say that we want to insert a new parameter somewhere into the interface instead of just adding it on the end. As an example, we'll try and add a button to the Mantra ROP that when clicked will show the image in MPlay given by the current render path. Given the intended functionality, it makes most sense to add it next to the Output Path (vm_picture) parameter. We could talk about the UX design merits of placing it before or after the parameter, but let's leave that discussion, we could be here all day! We'll just add the button after it for now.


First, we need to specify where we want to insert it. To do that, you can ask the ParmTemplateGroup to find the "vm_picture" parameter and return the "indices".

>>> indices = ptg.findIndices("vm_picture")
>>> print indices
(13, 4)

Note that we're providing the name of the parameter to "findIndices", not the label.


The indices describe the hierarchical location where our parameter is located in the UI. The size of the returned indices array depends on how deeply nested the parameter is inside its parent folders. In this case, the "vm_picture" parameter is the 5th parameter in its parent folder. That parent folder is then the 14th item in the overall interface (the indices are zero based).


Using these indices, we can now add our button:

button_parm = hou.ButtonParmTemplate("show_in_mplay", 
                                     "Show in MPlay")
ptg.insertAfter(indices, button_parm)
node.setParmTemplateGroup(ptg)

Which will look like this, where we can now see the new "Show in MPlay" button:

Note that we could have removed a step by passing the parameter name to "insertAfter()" instead like this:

ptg.insertAfter("vm_picture", button_parm)

That's fine if you're only doing referring to "vm_picture" once. If you need to refer to it multiple times, then it's more performant to look up the indices yourself and reuse them. I just did it here to illustrate both ways of doing it.


Adding a Callback


Let's get the "Show in MPlay" button to actually do something. If you've been following along with the previous parts, you'll know that we made a simple pipeline library to allow us to test our code. We'll now add a routine to the "pipeline.houdini.tools" module that looks like this:

import subprocess
import hou

def open_parm_in_mplay(image_file_parm):
    with hou.ScriptEvalContext(image_file_parm):
        file_expr = image_file_parm.rawValue()
        file_expr = file_expr.replace("$F4", "*")
        path = hou.expandString(file_expr)
        subprocess.call(["mplay", path])

This routine expects the argument to be a parameter containing a path to some image(s). The rest of the routine processes the filename expression that starts off looking like this:

$HIP/render/$HIPNAME.$OS.$F4.exr

Into something like this:

/job/project/houdini/hip/render/test_render.mantra1.*.exr

Notice how we've replaced the frame number with an asterisk. That tells MPlay to load all the files matching that pattern regardless of frame number.


It's worth pointing out that we're being fairly simplistic here and assuming we're always using "$F4" as our frame number. That's fine as this is just a demonstration, but in practice you would want to make this routine more robust and support $F with or without any number as a suffix. Using "sub" from the regular expression "re" module would help with this. You'd also need to handle using the curly bracket syntax too, e.g. "${F4}".


One other bit of polish would be to check that the files exist before launching MPlay. That way you can give appropriate feedback to the user if the images can't be found. The "glob" Pyth0n module would be handy for using the wildcard to find if the images exist or not.


In case you're wondering, the "hou.ScriptEvalContext()" is a Python context manager that tells Houdini to set the current evaluation context to the given node or, in this case, a parameter. That means when we call "expandString()" it evaluates things like "$OS" properly.


We can now use this function when we create our ButtonParmTemplate. We just include two lines of Python to call our "open_parm_in_mplay()" utility function.

button_callback = """from pipeline.houdini import tools
tools.open_parm_in_mplay(kwargs["node"].parm("vm_picture"))"""

button_parm = hou.ButtonParmTemplate(
                  "show_in_mplay", 
                  "Show in MPlay",
                  script_callback=button_callback,
                  script_callback_language= 
                      hou.scriptLanguage.Python)

Better Parameter Creation


From a programming perspective, creating parameter in the way I've shown above is not ideal. Particularly when you have a lot of parameters to create. Your code can easily get cluttered and hard to read. That's when errors tend to sneak in.


In situations like this, I try to see if I can separate the information from the logic. The idea is to write a small amount of code that is able to process our data. We make it "data-driven".


In this case, this is a fairly easy thing to do. We just store the set up for each parameter in a dictionary. We can pass the dictionary to the parameter's constructor directly by using the double asterisk syntax. Going back to our float parameter example, we might store our parameter like this:

parm_data = {
    "type": hou.FloatParmTemplate,
    "name": "new_float_parm",
    "label": "New Float Parm",
    "num_components": 1
    }

We can then write a function like this which will make our parameter:

def create_parm(parm_data):
    parm_data = parm_data.copy()
    parm_type = parm_data.pop("type")
    return parm_type(**parm_data)

This works because the keywords in the "parm_data" dictionary are the same as the argument names for the FloatParmTemplate.


Notice that we make a copy of the parameter data before we start. That's because we need to remove the "type" keyword from the dictionary because it's not a valid argument to the parameter creation. It's only a slight runtime inefficiency, but could be avoided by putting the other keywords in a different dictionary. However, this does add clutter with the extra level of depth, so I prefer to keep the data as visually clean as possible, but choose whatever works for you.


Adding Folder Support


We can easily support parameters inside any number of nested folders using this system. For example, this shows a single folder containing our second float parameter:

parm_data = {
    "type": hou.FolderParmTemplate,
    "name": "my_folder", 
    "label": "New Folder", 
    "folder_type": hou.folderType.Tabs,
    "_children": 
        [
            {
                "type": hou.FloatParmTemplate,
                "name": "new_float_parm_2",
                "label": "New Float Parm 2",
                "num_components": 1
            }
        ]
    }

Our parameter creation function now becomes slightly more complex as it becomes recursive:

def create_parm(parm_data):
    parm_data = parm_data.copy()
    parm_type = parm_data.pop("type")
    if parm_type == hou.FolderParmTemplate:
        parm_data_list = parm_data.pop("_children")
        folder_parm = parm_type(**parm_data)
        for parm_data in parm_data_list:
            child_parm = create_parm(parm_data)
            folder_parm.addParmTemplate(child_parm)
        return folder_parm
    else:
        return parm_type(**parm_data)

It's rare for folder depths to go much beyond 5 at most, so I'm not too concerned about any inefficiencies with the recursion.


To create and add the parameters, we can do this:

node = hou.node('/out/mantra1')
ptg = node.parmTemplateGroup()
parm = create_parm(parm_data)
ptg.append(parm)
node.setParmTemplateGroup(ptg)

and it will add our folder with the float parameter inside to the Mantra ROP.


What Are The Benefits?


This might seem a fairly trivial change, but believe me, when you have a lot of parameters nested in folders to add it can really help with the readability and maintenance of the code.


Dealing with parameters as data instead of code gives you the flexibility to do a lot of things that would otherwise be tricky.

  • It makes it super simple to reuse sections of user interface and customise them as you go. You just make copies of the data and you then have the flexibility to add/tweak the parameter data before passing it to the "create_parm()" function to create the parameters. If a node type doesn't need a particular parameter, just delete it from the data before applying it.

  • You can easily merge and append parameter data structures before the parameters have even been created.

  • You can generate these data structures automatically. You would design the interface using Houdini's UI, and a script could interpret your changes and save the data to a file which you could use for automating these modifications.


Limitations


There are a few limitations to be aware of when trying to create parameters with this method, but they're not major issues and can easily be worked around.


Firstly, for some ParmTemplates, some common functionality isn't exposed for all types through the constructor. For example, the FolderParmTemplate class doesn't allow you to specify a default expression or script callback when you create it. Why would you need those for a folder I hear you ask? Well, don't forget that folders can be multi-parm containers, so it's actually quite useful to define default expressions and callbacks for them.


To work around it when dealing with a FolderParmTemplate, you can just add a check for those arguments, pop them out of the parm_data and set those attributes using the regular methods after the FolderParmTemplate has been created.


Similarly, not all parameters support the "hide_when" and "disable_when" arguments which are useful. Again, you can easily add support by following the same principle as just mentioned. This time, you would call "setTabConditional()" on the parm template after creation with appropriate values.


Summary


That's the end of this 3 part article in modifying Houdini nodes. Hopefully it's given you some insight into what's possible in Houdini when customising it to suit the needs of you and your team.


Feel free to use the comments section to leave feedback, ask questions, or tell me about how you've approached this yourself!

165 views0 comments
  • Facebook
  • Twitter
  • LinkedIn

©2020 by andynicholas.com