Houdini Scene Export To Unreal

Today I’m sharing at shelf tool that I wrote for Houdini, but the end result can be duplicated in other 3D applications like Blender. All you need is to follow the format and export Object Name, Position, Rotation, Scaling like I have. Here’s a sample of how that output looks:

ObjName,Position,Rotation,Scale
Mesh_TestCube01,"[0.0, 0.0, 0.0]","[0.0, -45.0, 0.0]","[1.0, 1.0, 1.0]"
Mesh_TestCube02,"[-0.4, 1.25, 0.0]","[0.0, 0.0, 0.0]","[0.1, 0.5, 0.5]"

Not too intimidating, right? My aim was to make this as simple as possible, so any 3D modeling application can produce this output.

Here’s the shelf tool Python script, I’ll explain the design assumptions after the code block:


# --- Modules

import csv, os, sys
import datetime # Console timestamping
import math # Truncating decimals

# --- Variables

exportNames = []
exportPaths = []
exportPropList = []

# --- Functions

def childrenOfNode(node, filter): # Returns full path for filter type
paths = []

if node != None:
for n in node.children():
t = str(n.type())
if t != None:
for filter_item in filter:
if (t.find(filter_item) != -1):
# Append raw path list matching filter
paths.append(n.path())

return paths

def truncate(number, decimals=0): # Truncates decimals to a given precision
factor = 10.0 ** decimals

return math.trunc(number * factor) / factor

def vectorToFloats(myVector): # Takes vector object and returns elements
tempFloatList = []
# Need to truncate values, currently getting 16-decimal precision, lol
# Functionally same as myVector.x()
tempX = truncate(myVector[0], 3)
tempY = truncate(myVector[1], 3)
tempZ = truncate(myVector[2], 3)
tempFloatList = [tempX, tempY, tempZ]

return tempFloatList

def getFBXPrefix(myNodePath): # Uses node path to extract fbx prefix
# When constructing scenes, I'm using file nodes to load the exported .fbx
# of individual assets - so I need to distinguish from a scene that has them
# versus one that does not

if hou.node(myNodePath + '/rop_fbx1') is None:
fbxNode = hou.node(myNodePath + '/file1')
fbxFileName = fbxNode.parm('file').eval()
else:
fbxNode = hou.node(myNodePath + '/rop_fbx1')
fbxFileName = fbxNode.parm('sopoutput').eval() # Get param value

#print("FBX output parameter is: " + fbxFileName + "\n")
# split slashes
fbxNameSplit = fbxFileName.split('/')
# Get last element for output filename
fbxNameRaw = fbxNameSplit[-1]
# Split out .fbx extension
fbxNameSplit = fbxNameRaw.split('.')
# Get FBX output filename
fbxOutputName = fbxNameSplit[0]
print("Output fbx file prefix is: " + fbxOutputName + "\n")

return fbxOutputName

def getNodePosRotScale(myNodePath): # Gets info, returns list
tempObjList = [] # Temp list to store properties
tempPathSplit = myNodePath.split('/')
# Instead of using object name, using output filename prefix from rop_fbx1
tempObjName = getFBXPrefix(myNodePath)
# Get next to last element of path split for obj name
#tempObjName = tempPathSplit[(len(tempPathSplit)-1)]
# Get reference to node
tempObj = hou.node(myNodePath)
# Get world transform
tempObjWorld = tempObj.worldTransform()
# Get position as a vector
tempObjPos = tempObjWorld.extractTranslates() # 'srt' is the default
# Get rotations
tempObjRot = tempObjWorld.extractRotates()
# Get Scaling
tempObjScale = tempObjWorld.extractScales()
# Debug - objects are vector3, messing with casting/stripping strings
#print("Pos: " + str(tempObjPos).split(','))
# Need to figure out casting from Vector3 to string
vecPosFloats = vectorToFloats(tempObjPos)
vecRotFloats = vectorToFloats(tempObjRot)
vecScaleFloats = vectorToFloats(tempObjScale)
# Debug
#print(str(vecPosFloats))
# Populate list - name, position, rotation, scaling
#tempObjList = [tempObjName, tempObjPos, tempObjRot, tempObjScale]
tempObjList = [tempObjName, vecPosFloats, vecRotFloats, vecScaleFloats]

return tempObjList


# --- Main Exec

# Filter For Object Geo Nodes
node_root_path = '/obj'

exportPathsRaw = childrenOfNode(hou.node(node_root_path),["Object geo"])

# Clear console a bit
print('\n' * 4)

# Debug
timeStamp = datetime.datetime.now()
print("\n ----------[ TallTim - CSV To Unreal Export Tool at " + str(timeStamp) + " ]---------- \n")

# Search for UEA suffix in object node names
for pathItem in exportPathsRaw:
pathSplit = pathItem.split('_')
# Debug
print("UEA Search loop - Path Item is: " + pathItem)
# Error on ScaleReference_UEA
# AttributeError: 'NoneType' object has no attribute 'parm'
# I have to look at file nodes

if pathSplit[-1] == 'UEA': # Got export suffix?
# Get node information using path
myListResult = getNodePosRotScale(pathItem)
print(myListResult)
print("\n")
exportPropList.append(myListResult) # Build final list

csvPath = '<YourExportPathHere>'
# With quote MINIMAL option headers appear as they should
csvHeaders = ['ObjName','Position','Rotation','Scale']
# Options - NONNUMERIC, MINIMAL, NONE - requires escapechar='<char>'
#csvQuoteType = csv.QUOTE_NONNUMERIC
csvQuoteType = csv.QUOTE_MINIMAL
#csvQuoteType = csv.QUOTE_NONE

# Get hip project filename for scene export
projNameRaw = os.path.dirname(hou.hipFile.name())
# Split out slashes
projNameSplit = projNameRaw.split('/')
# Get project name from file
projName = projNameSplit[-1]
# Full write path and filename
csvPathFilename = csvPath + projName + '.csv'

# Open file for writing CSV
with open(csvPathFilename, mode='w', encoding='utf-8') as csvfile:
# Create writer object for file
writer = csv.writer(csvfile, delimiter=',', quotechar='"', quoting=csvQuoteType, lineterminator='\n')
# Write header row
writer.writerow(csvHeaders)
# Iterate final list and write rows
for propRow in exportPropList:
writer.writerow(propRow)

Houdini is a node-based system, so any SOP (Surface OPerator – anything that makes meshes) can have a name assigned to it. This looks for a name format like: YourMeshName_UEA the suffix stands for “Unreal Engine Asset”, and was just a way for me to differentiate between objects I was exporting and those I were not, like cameras and simulations, etc..

The script then queries all those objects for their parameters and builds rows for the CSV file before finally writing the header and that data at the end. Blender supports Python, so I’m sure someone could figure out how to do this as well pretty easily. The end result is a file that is named after the “scene” filename, so something like “LevelTest01.csv” is the output.

If you pair this with my Unreal Engine Scene importer, and point that script at the proper root folder where your assets live, it will use this information to replicate your scene, without having to do one bit of work in Unreal, which saves a lot of time.

Here’s the Scene Importer script for Unreal:

##  ______      ___________          _      
## /_ __/___ _/ / /_ __(_)___ ___ ( )_____
## / / / __ `/ / / / / / / __ `__ \|// ___/
## / / / /_/ / / / / / / / / / / / / (__ )
##/_/ \__,_/_/_/ /_/ /_/_/ /_/ /_/ /____/
##
## Unreal Engine Asset Spawner - Exported CSV Sets Position, Rotation, Scale
## Less manual drudgery, more asset creation!
##
## This takes a .csv file written from Houdini and spawns meshes with the correct settings
## The eventual goal is to make it so an arbitrary marker can be used to adjust multiple assets
## in a scene dynamically in UE to aid in level design. (Not implemented yet.)

## CSV Export format is: (So any program like say, Blender, etc that can use scripts to write a CSV file will work.)
## ObjName,Position,Rotation,Scale
## Mesh_LevelBlock01,"[0.0, -0.35, 0.0]","[0.0, 0.0, 0.0]","[1.0, 1.0, 1.0]"

# ---- Modules

import unreal
from unreal import Vector # Fun with vectors
from unreal import Rotator # fun with rotations
import os
import csv
import pandas as pd
import pathlib # For directory structure scanning

# ---- Variables
myDataPath = '<Your path to the exported CSV file here>'
myCSVFile = '<Your CSV file name>.csv'

myProjectPath = '<Your root project asset path here>' # Root path to scan for meshes to import

tempObjList = []
tempPropertyList = []
assetMeshPathList = []
resultFlag = None

priorAsset = "Nothing" # Keeps track of assets, so we don't bother loading in duplicate object references in UE
objIndexCounter = 0 # Initialize object index counter - handles dupe objects in Scene CSV file
files = os.listdir(myDataPath) # Get directory contents

df_SceneList = pd.DataFrame()
meshFilePrefixList = []

# ---- Functions

def dumpListContents(myInputList):
for item in myInputList:
# Note - unreal warning messages will show 'None' at end of list, but this is not an element in the list itself
unreal.log_warning(item)
#print(item) # Shows list normally

return

# Gets all actors in scene, useful for some debugging
def dumpLevelActorsList():
actorsList = unreal.EditorLevelLibrary.get_all_level_actors()

for actor in actorsList:
actorLabel = actor.get_actor_label()
actorPos = actor.get_actor_location()

if (actorLabel == 'YourActorLabelHere'):
unreal.log_warning('actorLabel= %s actorPos=%s' % (actorLabel, actorPos))

return

# Takes path/filename.csv and throws it into a list - deprecated, using pandas dataframes
# But useful if you want to play with lists instead
def readCSVFile(myFile):
tempSceneList = []
with open(myDataPath + '/' + myFile, mode='r') as file:
csv_data = csv.reader(file)
for row in csv_data:
tempSceneList.append(row)

return

# This function takes a set object, output list and converts to a list of strings
def convertSet(mySetObject, myOutputList):
for item in mySetObject:
tempstr = str(item)
myOutputList.append(tempstr)

return

def readProjectMeshes(assetRootPath):
# Temp destination path
tempDestPath = ""
# temp Mesh list
FBXList = []
# Get directory contents under Assets
assetListRaw = pathlib.Path(myProjectPath) # Set root directory to recursively make list from
# Isolate FBX, UDIM Textures
FBX_Assets = assetListRaw.rglob("*.fbx") # Grab our mesh file paths
# Convert from rglob to set
FBX_SetObject = map(str, FBX_Assets)
#Texture_SetObject = map(str, Texture_Assets)
# Iterate set objects and convert to string list
convertSet(FBX_SetObject, FBXList)
#convertSet(Texture_SetObject, TextureList)

return FBXList#, TextureList # Return mesh list for processing

def extractDestPath(myPathRaw): # This takes the first raw fbx import path and determines structure for destination
tempIndex = -1
destPathList = []
myDestPath = "/Game/<Your folder name here>" # This is your UE destination path - '/Game' is always root
tempPathLength = -1
tempSplitPath = myPathRaw.split('\\')
# Determine where "Assets" begins
for idx, folder in enumerate(tempSplitPath):
if folder == "Assets":
tempIndex = idx

# Now iterate based on start index and build the destination path
for idx, folder in enumerate(tempSplitPath):
if idx >= tempIndex:
destPathList.append(folder)

tempPathLength = len(destPathList)
# Iterate final list and build destination path, with '/Game' as root
for idx, folder in enumerate(destPathList):
if idx <= (tempPathLength-2): # Leave off last element since its a file
myDestPath = myDestPath + '/' + folder

# Store the mesh names without the extension here
if idx == (tempPathLength-1): # Get last element for filenames
# Debug
#unreal.log_warning("Last elment is: " + folder) # gives Mesh_<name>.fbx
# split out the file extension
folder_split = folder.split('.')
# Get filename element
folder_Filename = folder_split[0]
# Debug
#unreal.log_warning("Mesh filename is: " + folder_Filename)
meshFilePrefixList.append(folder_Filename) # Store filename prefix result
# Debug
#unreal.log_warning("Extracted destination path is: " + myDestPath)

return myDestPath

def stringToList(myString): # Converts exported strings to floats - format '[x, y, z]'
# Debug
#print("StringToListFunc - Type being passed in is: ", type(myString)) # show type... log warning doesn't support this
#unreal.log_warning("StringToList Func - string to convert is: " + myString)
# Strip the '[' and ']' from the string
stripLeft = myString.strip('[')
stripFinal = stripLeft.strip(']')
# Split the result using ', ' separator
tempList = stripFinal.split(', ')
# Convert list to floats
tempListFloat = [float(item) for item in tempList]

return tempListFloat

# This checks against the scene object list and returns True/False
def checkSceneList(myAssetName):
for sceneObj in tempObjList:
if myAssetName == sceneObj:
resultFlag = True
return resultFlag
else:
resultFlag = False

return resultFlag

# ---- Main Execution Steps

# Debug - using warning color to highlight output for visibility in the UE5 Log Window
unreal.log_warning('.')
unreal.log_warning("----------[ TallTim's Asset Spawner And Property Settings Utility ]----------")
unreal.log_warning('.')

df_SceneList = pd.read_csv(myDataPath + '/' + myCSVFile)

# Get number of dataframe rows and columns
dataDimensions = df_SceneList.shape
dataRows = dataDimensions[0]
dataCols = dataDimensions[1]
# Debug
#unreal.log_warning("Scene List dimensions - Columns: " + str(dataCols) + " Rows: " + str(dataRows))

# Debug - print dataframe Contents
unreal.log_warning("Scene List Dataframe Contents: \n" + df_SceneList.to_string())

# Iterate rows to populate a list of objects to find in the Content Browser
for row in range(dataRows):
tempObjName = df_SceneList.loc[row, "ObjName"]
# Select Object Name column and append value
if tempObjName != "Mesh_Marker": # Filtering for top-level OBJ name on the marker - just for testing
tempObjList.append(tempObjName)

# Debug
#unreal.log_warning(dumpListContents(tempPropertyList)) # Works

projectMeshPathRaw = readProjectMeshes(myProjectPath) # Returns list of meshes in root project path - mirrors the imported folder structure
# Debug
#unreal.log_warning("Paths list to meshes: ")
#unreal.log_warning(projectMeshPathRaw)

# This makes sure the project Mesh Path Raw elements equals the length of the object Scene File CSV
if len(projectMeshPathRaw) != dataRows:
# Store difference
sceneDiff = dataRows - len(projectMeshPathRaw)
# Get last element to append
tempMeshPath = projectMeshPathRaw[-1]
# Debug
#unreal.log_warning("Project meshes don't equal scene file mesh names, checking for duplicates in Scene CSV File.")
#unreal.log_warning("Difference (Scene Rows - Project Mesh Names): " + str(sceneDiff))
# Append number elements so it equals CSV Scene rows
for i in range(sceneDiff):
projectMeshPathRaw.append(tempMeshPath)

#else: # Debug
# unreal.log_warning("Project meshes equals scene file mesh names, continuing with processing.")

# For each Mesh path found in the project folder structure, extract the destination path to load references
for meshPath in projectMeshPathRaw:
assetMeshPathList.append(extractDestPath(meshPath))

# Debug
#unreal.log_warning("Extracted paths to imported meshes: ")
#unreal.log_warning(assetMeshPathList)

# Debug - Object names
#unreal.log_warning("Scene Object Contents From CSV File: ")
#unreal.log_warning(dumpListContents(tempObjList))
#unreal.log_warning("Object list length is: " + str(len(tempObjList)))

# Debug
#unreal.log_warning("Prior to main loop, Asset Mesh Path List holds: ")
#unreal.log_warning(assetMeshPathList)

# Process each mesh asset and set properties
for idx, asset_path in enumerate(assetMeshPathList):
tempLoadAssetPath = asset_path + '/' + meshFilePrefixList[idx]
# Debug
#unreal.log_warning("Loading path to spawn: " + tempLoadAssetPath)
assetPrefix = meshFilePrefixList[idx] # Get meshfile name and add it to path
sceneCheckFlag = checkSceneList(assetPrefix) # Checks if asset is in the scene dataframe, "Marker" is filtered out for now...
# Debug
#unreal.log_warning("Scene check result is: " + str(sceneCheckFlag))
# Only attempt to load/set values for objects that pass the scene check
if sceneCheckFlag == True:
# Debug
#unreal.log_warning("Prior Asset is: " + priorAsset)
# Avoid loading more than one object reference when objects are duplicated in the Scene CSV file
if assetPrefix != priorAsset: # Not an object dupe?
finalLoadAssetPath = tempLoadAssetPath + '.' + assetPrefix
tempObjRef = unreal.load_asset(finalLoadAssetPath) # Assign reference - path.meshfileprefix
priorAsset = assetPrefix
priorObjRef = tempObjRef # Assign prior object reference to use if duplicates found
# Debug
#unreal.log_warning("Unique Asset to set parameters is: " + assetPrefix)
#df_tempSceneIndex = df_SceneList.loc[:, ["ObjName"]] # This gives a dataframe with only the ObjName column
# Debug
#print("Temp Scene Index is: ")
#print(df_tempSceneIndex)
#print("Index: " + str(objIndexCounter) + " element is: " + df_tempSceneIndex.iloc[objIndexCounter]["ObjName"])
# Assign lists for Pos,Rot,Scale vectors for Unique asset
posListRaw = df_SceneList.iloc[objIndexCounter]["Position"]
rotListRaw = df_SceneList.iloc[objIndexCounter]["Rotation"]
scaleListRaw = df_SceneList.iloc[objIndexCounter]["Scale"]
# Process strings into float-casted lists
posList = stringToList(posListRaw)
rotList = stringToList(rotListRaw)
scaleList = stringToList(scaleListRaw)
# Increment index counter
objIndexCounter += 1
else:
# If asset prefix equals prior - its a Duplicate
tempObjRef = priorObjRef # use the prior stored object reference
# Debug
#unreal.log_warning("Duplicate Asset to set parameters is: " + priorAsset)
# Assign lists for Pos,Rot,Scale vectors for Duplicate asset
posListRaw = df_SceneList.iloc[objIndexCounter]["Position"]
rotListRaw = df_SceneList.iloc[objIndexCounter]["Rotation"]
scaleListRaw = df_SceneList.iloc[objIndexCounter]["Scale"]
# Process strings into float-casted lists
posList = stringToList(posListRaw)
rotList = stringToList(rotListRaw)
scaleList = stringToList(scaleListRaw)
# Increment index counter for next object
objIndexCounter += 1

# Now we do our final property settings for the asset
unit_factor = 100 # Compensates for Houdini units to Unreal Engine
# FBX Meshes are exported from Houdini with "Y-Up Right Handed", but the "Convert to specified axis system" and "Convert Units" is checked
# Position/Translation X, Z, Y - Unreal uses Z-up
objPosition = Vector(posList[0]*unit_factor, posList[2]*unit_factor, posList[1]*unit_factor)
# Rotation X, Z, Y
objRotation = Rotator(rotList[0], rotList[2], rotList[1])
# Scale X, Z, Y
objScale = Vector(scaleList[0], scaleList[2], scaleList[1])
# Apply Position and Rotation to spawned object
tempObjSpawn = unreal.EditorLevelLibrary.spawn_actor_from_object(tempObjRef, objPosition, objRotation)
# Apply scaling to spawned object
tempObjSpawn.set_actor_scale3d(objScale)

More to come, as I get these tools for my pipeline together. Note – The above assumes you are using the FBX Importer for Unreal I wrote in this post.