Houdini FX - FBX Export With UDIM To UE5 Oddities

   Views 3020   Replies 10   Subscribers 1
User Avatar
Member
27 posts
Joined: May 2022
Offline
Hello, been banging my head against a few things so I figure I'd share since it seems UE 5.3 does NOT like Houdini's UDIM "token" references when exporting a FBX with UDIM textures.

I've gotten my model set up to use UDIM textures just fine in Houdini FX 19.5.752:



I want to push this to Unreal, but I'm not really a fan of session/nodesync, so I thought I could just export a FBX and UE5 would pull the texture references in and it would be fine, right? Nope.

I get a UE5 log error on import (path redacted for brevity):

"LogFbxMaterialImport: Display: Unable to find Texture file (absolute path)/IntroBasicMonitor06_DefaultMaterial_BaseColor.%(UDIM)d.png"

This results in a FBX model being imported, and since I asked the UE5 importer to create materials, the material does exist, but it has one blank base texture assigned to it.





Could I wire it up in UE5 myself? Sure, but I also have to import the UDIM/Virtual Textures as well. I could automate this (looking into that now), but it bothers me that the Houdini FX 'tokens' used to specify materials that works in Houdini doesn't translate into UE5 at all.

As an experiment, I exported the FBX as ASCII versus Binary, and went in and edited all references to the UDIM token '%(UDIM)d' to '1001', just to see if UE5 would understand its part of a set, which it does when you drag the '1001' "head" textures of a UDIM set into the UE5 content browser.

Still nope, it did import the textures when I tried with the edited FBX file, and it did create a material with base/normal/metallic wired up, but it interpreted them as a simple 'legacy' textures instead of UDIM/Virtual ones.

So as you can imagine, the result was a mess -- but the model was automagically textured!





Here's the pics where I manually imported the UDIM/Virtual Texures and wired up the material myself:





Just wondering if there's anyone out there that has actually done FBX export with UDIMs and made it work. Just importing the file generated by Houdini FX, nothing else.

I also like to add that I do have the Houdini Engine plugin up and functioning in UE5 for this project, if it matters.

Thanks!
Edited by TallTim - Nov. 2, 2023 11:47:47
User Avatar
Member
27 posts
Joined: May 2022
Offline
200+ Views and nobody knows, eh? Well, I've gone down the automation route with UE5's Python API.

Guess I'll turn this into a 'liveblog' on that solution. Still open to any other pointers though, don't be shy.

I found this video [www.youtube.com] that does explain importing assets using Python, which I will be basing this on.

Two ways I could do this, but the simplest is to just make a "Editor Utility Blueprint" and then invoke it from the Content Browser. I also could make it more dynamic and have it watch my project folders and import and wire-up my assets if they don't exist already, but I leave that exercise to the reader.

Just for reference, here's how to set up that Blueprint [www.rendereverything.com].

Here's the UE5 Python API [docs.unrealengine.com].

I've already gotten the .fbx and the UDIM texture sets working on import, I'll share more when I get the automatic texture setup done.
Edited by TallTim - Nov. 6, 2023 09:06:24
User Avatar
Member
27 posts
Joined: May 2022
Offline
This has been rather annoying.

Diving into APIs, bare-bone docs that don't bother with code examples, etc. The examples I have found end up going over the river and through the woods into a "Backrooms" pocket dimension before you hit the tenth line of code.

I'm going to make it a simple step-by-step, with a hardcoded and inflexible implementation because I wanted to understand it first before I added a warp drive and decided to fly to Alpha Centauri (metaphorically).

First, lets load our assets from a defined list that is simply <path><assetname><extension>. The loader will understand the different types, so that's nice.

<texthere> Means to substitute your own things, of course.

# Some basic setup, importing the main module and then setting a few things for easier reference

import unreal

# These references save you typing all this over and over
AT = unreal.AssetToolsHelpers.get_asset_tools()
AID = unreal.AutomatedAssetImportData()
EAL = unreal.EditorAssetLibrary

# Set paths to make the list easier to construct - Windows paths use '/' here
importBasePath = '<drive letter>:/<your path to a FBX file>'
importTexturePath = '<drive letter>:/<your path to UDIM texture files>'

# This is the list to our assets - you only need to include the 'head' of the UDIM sets here, UE5 understands they're virtual
# I'm using Substance Painter, so the exported names use the conventions below. Substitute for whatever yours happens to be
importFileNames = [importBasePath + 'yourMeshName.fbx', 
importTexturePath + '<YourTextureName>_DefaultMaterial_BaseColor.1001.png', 
importTexturePath + '<YourTextureName>_DefaultMaterial_Normal.1001.png', 
importTexturePath + '<YourTextureName>_DefaultMaterial_OcclusionRoughnessMetallic.1001.png'] 

# Now we set the destination for our assets - /Game is the default root here in the UE5 Content Browser
AID.destination_path = '/Game/<your path where you want this>'
AID.filenames = importFileNames

# Optionally when testing you may want to just replace things, this statement does that
AID.replace_existing = True

# Now lets import using our list
AT.import_assets_automated(AID)

# Now lets create a new blank material
AT.create_asset(asset_name='YourMaterialName', package_path='/Game/<your path where you want this>', asset_class=unreal.Material, factory=unreal.MaterialFactoryNew())

# To add -- Wiring up the UDIM textures to this blank material.... I haven't done this part yet, I'll update when I do...

# Assign the texture to the mesh
# Maybe there's a more elegant way to do this, but I just loaded a reference to the mesh and the blank texture

# Mesh reference
myAsset = unreal.load_asset('/Game/<your path>/<your mesh name>')

# Texture reference
myMatToAssign = unreal.load_asset('/Game/<your path>/<your texture name>')

# Set material
myAsset.set_material(0, myMatToAssign)

I do need to figure out the 'wiring' step, but these steps will assign the imported mesh to your newly created texture.

I'll update when I've got more to share...
Edited by TallTim - Nov. 5, 2023 21:37:42
User Avatar
Member
8127 posts
Joined: Sept. 2011
Offline
TallTim
Hello, been banging my head against a few things so I figure I'd share since it seems UE 5.3 does NOT like Houdini's UDIM "token" references when exporting a FBX with UDIM textures.

what happens if you use the modern token instead of the legacy one? <UDIM> instead of %(UDIM)d

I'm surprised UDIM is even a thing in a game engine, I thought UDIM was solely in the realm of VFX assets.
User Avatar
Member
27 posts
Joined: May 2022
Offline
jsmack
TallTim
Hello, been banging my head against a few things so I figure I'd share since it seems UE 5.3 does NOT like Houdini's UDIM "token" references when exporting a FBX with UDIM textures.

what happens if you use the modern token instead of the legacy one? <UDIM> instead of %(UDIM)d

I'm surprised UDIM is even a thing in a game engine, I thought UDIM was solely in the realm of VFX assets.


Good question, and yes, I did try it. Still fails -- it imports the .fbx (and since I have 'Create Material' checked) it has a material with a default base color wired to it.

That's why I went through the extra steps of trying to just substitute the token inside the ASCII exported FBX with actual texture names, which also didn't achieve the right results as I posted before.

Edit -- Just to be absolutely sure, I tried it again and I got the same log errors in UE 5.3 (path/name replaced for brevity):

LogFbxMaterialImport: Display: Unable to find Texture file <path><texturename>_DefaultMaterial_BaseColor.<UDIM>.png
Edited by TallTim - Nov. 5, 2023 15:07:44
User Avatar
Member
27 posts
Joined: May 2022
Offline
I'm still working on stuff

I realized early on that I'll need to read my project folder in and do some manipulations so I can populate the "importFileNames" list.

I figured out that using the pathlib Python module and rglob will give me what I want:

import pathlib

myProjectPath = 'X:\SomeProjectName\Assets\InteriorProps' # Your 'root' path here
# Global list vars for asset lists
FBXList = []
TextureList = []
	
# This utility 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

# This function takes your project root and makes different string element lists for further processing
def readProjectAssets(assetRootPath):
	# Get directory contents under Assets
	assetListRaw = pathlib.Path(assetRootPath) # Set root directory to recursively make list from
	# Isolate FBX, UDIM Textures
	FBX_Assets = assetListRaw.rglob("*.fbx")
	Texture_Assets = assetListRaw.rglob("*_DefaultMaterial_*.1001.*") # Only returns the 'head' of UDIM texture sets
	# 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)

	# Debug to check the output

	print("\n FBX List: \n") 
	print(FBXList)
	print("\n Texture List: \n")
	print(TextureList)
	print("\n First FBX element: " + FBXList[0] + " \n")
	print("\n First Texture element: " + TextureList[0] + " \n")	
	
	return

# Execute the function 
readProjectAssets(myProjectPath)

With that done, all I need to do is point this at whatever root path I want, and it will recursively go through all the folders and sub-folders and find the filetypes I need, which is handy.

I'll update the example code I posted for the unreal engine bits when I make more progress with wiring up the blank texture.

Onward...
Edited by TallTim - Nov. 7, 2023 10:14:00
User Avatar
Member
27 posts
Joined: May 2022
Offline
You know that part in "The Hitchhiker's Guide" describing where the official plans for a highway (which was due to go right through Arthur Dent's house) were posted, in a dark cellar with broken stairs - at the bottom of a filing cabinet inside a disused lavatory bearing the sign "Beware of the Leopard" on the door?

That's how I feel doing research on this wiring bit. I'm glad I did it last, because it was way more annoying than the other parts - zero examples and after plumbing through fragments of code from 5 years ago, and a random video that hinted at how to do it, I finally figured out the incantations needed to make it work.

Just to demonstrate -- here's the function I wrote. I still have more work to do, and I'll paste the entire working script when I get it into shape. It will find all the assets needed and wire them up automagically.

Here's a teaser:

def wireUpTextures(myAssetName):
	# Debug
	unreal.log_warning("Wiring up texture with material name: " + myAssetName)
	# The flow seems to be, create material expression, then connect material property
	tempMatObject = unreal.load_asset(importDestPath + '/' + myAssetName)
	# UDIM texture references
	# Split out prefix to use asset name in reference
	UDIM_Name = (myAssetName.split('_'))[1]
	# Debug
	#unreal.log_warning("UDIM Name is: " + UDIM_Name)
	# Set up references to UDIM textures we've already imported into the content browser
	UDIM_Base_Color = unreal.load_asset(importDestPath + '/' + UDIM_Name + '_DefaultMaterial_BaseColor')
	UDIM_Normal = unreal.load_asset(importDestPath + '/' + UDIM_Name + '_DefaultMaterial_Normal')
	UDIM_Occlusion_Roughness_Metallic = unreal.load_asset(importDestPath + '/' + UDIM_Name + '_DefaultMaterial_OcclusionRoughnessMetallic')
	# Make Nodes - Target Material Reference, Sampler Node, X coord, Y coord
	Tex_BaseColor = MEL.create_material_expression(tempMatObject, unreal.MaterialExpressionTextureSample, -400, 0)
	Tex_Normal = MEL.create_material_expression(tempMatObject, unreal.MaterialExpressionTextureSample, -400, 300)
	Tex_OccRoughMetallic = MEL.create_material_expression(tempMatObject, unreal.MaterialExpressionTextureSample, -400, 600)	
	# Connect sampler Nodes to material 
	MEL.connect_material_property(Tex_BaseColor, "RGB", unreal.MaterialProperty.MP_BASE_COLOR)
	MEL.connect_material_property(Tex_Normal, "RGB", unreal.MaterialProperty.MP_NORMAL)
	# In this case different color channels represent properties:  Red = Occlusion, Green = Roughness, Blue = Metallic
	# Properties from unreal.MaterialProperty: 
	# 'MP_AMBIENT_OCCLUSION', 'MP_ANISOTROPY', 'MP_BASE_COLOR', 'MP_EMISSIVE_COLOR', 'MP_METALLIC', 'MP_NORMAL', 'MP_OPACITY', 'MP_OPACITY_MASK', 'MP_REFRACTION', 'MP_ROUGHNESS', 'MP_SPECULAR', 'MP_SUBSURFACE_COLOR', 'MP_TANGENT'
	MEL.connect_material_property(Tex_OccRoughMetallic, "R", unreal.MaterialProperty.MP_AMBIENT_OCCLUSION)
	MEL.connect_material_property(Tex_OccRoughMetallic, "G", unreal.MaterialProperty.MP_ROUGHNESS)
	MEL.connect_material_property(Tex_OccRoughMetallic, "B", unreal.MaterialProperty.MP_METALLIC)	
	# Set Texture Sample nodes to UDIM texture reference
	Tex_BaseColor.texture = UDIM_Base_Color
	Tex_Normal.texture = UDIM_Normal
	Tex_OccRoughMetallic.texture = UDIM_Occlusion_Roughness_Metallic	
	# Set sampler node type for Virtual Color / UDIM
	# Properties from unreal.MaterialSamplerType: 
	# SAMPLERTYPE_ALPHA', 'SAMPLERTYPE_COLOR', 'SAMPLERTYPE_DATA', 'SAMPLERTYPE_DISTANCE_FIELD_FONT', 'SAMPLERTYPE_EXTERNAL', 'SAMPLERTYPE_GRAYSCALE', 'SAMPLERTYPE_LINEAR_COLOR', 'SAMPLERTYPE_LINEAR_GRAYSCALE', 'SAMPLERTYPE_MASKS', 'SAMPLERTYPE_NORMAL', 'SAMPLERTYPE_VIRTUAL_ALPHA', 'SAMPLERTYPE_VIRTUAL_COLOR', 'SAMPLERTYPE_VIRTUAL_GRAYSCALE', 'SAMPLERTYPE_VIRTUAL_LINEAR_COLOR', 'SAMPLERTYPE_VIRTUAL_LINEAR_GRAYSCALE', 'SAMPLERTYPE_VIRTUAL_MASKS', SAMPLERTYPE_VIRTUAL_NORMAL
	Tex_BaseColor.set_editor_property("SamplerType", unreal.MaterialSamplerType.SAMPLERTYPE_VIRTUAL_COLOR)
	Tex_Normal.set_editor_property("SamplerType", unreal.MaterialSamplerType.SAMPLERTYPE_VIRTUAL_NORMAL)
	Tex_OccRoughMetallic.set_editor_property("SamplerType", unreal.MaterialSamplerType.SAMPLERTYPE_VIRTUAL_COLOR)

	return

(MEL refers to the unreal.MaterialEditorLibrary)

Time for a break, I'll post more soon.
Edited by TallTim - Nov. 8, 2023 20:52:51
User Avatar
Member
27 posts
Joined: May 2022
Offline
Okay, here it is in its hacked-together-glory.

Funny story, I tried to share a link to this thread in the Xolotl Studios discord, but I guess it does too much for free and I got evicted, lol. Even though I paid for his plugin. Oops!

Some assumptions are:

1. Your project is set up where meshes exist in a < your path >< asset name > folder, with a < Textures > sub-folder underneath. If you don't supply any textures, it will still wire them up, but the sampler nodes will be blank with an 'error'. (This means a 'Textures' folder under every asset folder containing fbx meshes. If you don't like this, not too hard to change.)

2. I didn't implement any checks for assets already existing, but there's a comment in the code to give you a starting point if you want to do that. It may just be easier to change the root scan path to a specific subset of what you want, but I leave that to you.

3. This is pretty trivial now to change over to 'legacy' texture methods, you'll have to change the way rglob looks for those files instead of the UDIM '1001' method it uses now, but it isn't too hard. (In addition to some other minor changes in the wiring code.)

4. I included references to the different material properties and sampler node types so you can edit easily without diving into the UE5 Python API labyrinth. (You're welcome.)

5. Lots that could be improved, namely how it chooses where to create/import things in your root '/Game' folder in UE5, but this example is functional enough to be changed.

6. Not liable for it pushing things off your shelves, ruining dinner, or totally trashing a project you've been working on. (You do have backups, right? I would...)

Enjoy!

Now I can finally get back to making things in Houdini.

##  ______      ___________          _      
## /_  __/___ _/ / /_  __(_)___ ___ ( )_____
##  / / / __ `/ / / / / / / __ `__ \|// ___/
## / / / /_/ / / / / / / / / / / / / (__  ) 
##/_/  \__,_/_/_/ /_/ /_/_/ /_/ /_/ /____/  
##                                          
## Automagic Asset Import & Material Wiring Utility
## Less manual drudgery, more asset creation!
##
## November 9th, 2023 - Feel free to use, just give me a name credit somewhere...
##
## Note - This relies on a project structure like:
## < Root Project Path>< Project Asset FBX Directiory >
##			< Asset "Texture" Sub-directory >
## Failing to provide any textures in this sub-directory will result in a material created and 'wired' up, but the texture slots will be blank

# ---- Modules

import unreal
import pathlib # This is for filtering directories

# ---- Variables

test_flag = "False" # Just to make things easier when testing material assignment - Make sure to change the list used in the last FOR loop
#test_flag = "True"

# Unreal utility references
AT = unreal.AssetToolsHelpers.get_asset_tools()
AID = unreal.AutomatedAssetImportData()
MEL = unreal.MaterialEditingLibrary
EAL = unreal.EditorAssetLibrary

# Path declarations
importDestPath = '/Game/<YourDestFolder>' # Where the asset will be imported into the UE5 Content Browser '/Game' is always root
myProjectPath = '<Drive>:<YourProjectPath>' # Root path to scan for files

# Global list vars for asset lists
FBXList = []
TextureList = []

# New list from scanned folders
scannedFileNames = []

# Test fileset definitions - Just useful if doing dev testing on a smaller sub-set of assets
# Your project path to FBX meshes here
test_FBXList = ['<Drive>:<ProjectPath>\Mesh_<YourAssetName>.fbx']
# Your project path to Textures here - Set up for UDIMs
test_TextureList = ['<Drive>:<ProjectPath>\Textures\<YourAssetName>_DefaultMaterial_BaseColor.1001.png', 
'<Drive>:<ProjectPath>\Textures\<YourAssetName>_DefaultMaterial_Normal.1001.png', 
'<Drive>:<ProjectPath>\Textures\<YourAssetName>_DefaultMaterial_OcclusionRoughnessMetallic.1001.png']

# ---- Functions

# 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

# This function takes your project root and makes different string element lists for further processing
def readProjectAssets(assetRootPath):
	# Temp list merging all results
	masterOutputList = []
	# 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")
	Texture_Assets = assetListRaw.rglob("*_DefaultMaterial_*.1001.*") # Only returns the 'head' of UDIM texture sets
	# Convert from rglob to set - might be redundant, but whatever, I need to get some work done lol
	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)
	# A hand test flag just in case you want to do some debug on a limited set defined above	
	if test_flag == "False":
		masterOutputList = FBXList + TextureList
		
	if test_flag == "True":
		masterOutputList = test_FBXList + test_TextureList
	
	return masterOutputList

def importAssets(myAssetList):
	# Set import attributes
	AID.destination_path = importDestPath
	AID.filenames = myAssetList	
	AID.replace_existing = True # Supposed to replace existing, but when testing it seemed to prompt anyway
	# Check if assets exist using - if not unreal.EditorAssetLibrary.does_asset_exist(assetpath=<your /Game/path>)
	# Haven't implemented this, but its here if you want to attempt it
	# Do the import
	AT.import_assets_automated(AID)
	return

def createBlankMaterial(myMaterialName):
	tempPath = importDestPath
	tempName = myMaterialName

	unreal.AssetToolsHelpers.get_asset_tools().create_asset(asset_name=myMaterialName, package_path=tempPath, asset_class=unreal.Material, factory=unreal.MaterialFactoryNew())
	
	return tempPath, tempName

def wireUpTextures(myAssetName):
	# Debug
	unreal.log_warning("Wiring up texture with material name: " + myAssetName)
	# Get material reference for wiring operations
	tempMatObject = unreal.load_asset(importDestPath + '/' + myAssetName)
	# UDIM texture references
	# Split out prefix to use asset name in reference - naming convention is 'Mat_YourMaterialName' in this example
	UDIM_Name = (myAssetName.split('_'))[1]
	# Set up references to UDIM textures we've already imported into the content browser
	UDIM_Base_Color = unreal.load_asset(importDestPath + '/' + UDIM_Name + '_DefaultMaterial_BaseColor')
	UDIM_Normal = unreal.load_asset(importDestPath + '/' + UDIM_Name + '_DefaultMaterial_Normal')
	UDIM_Occlusion_Roughness_Metallic = unreal.load_asset(importDestPath + '/' + UDIM_Name + '_DefaultMaterial_OcclusionRoughnessMetallic')
	# Make Nodes - Target Material Reference, Sampler Node, X coord, Y coord
	Tex_BaseColor = MEL.create_material_expression(tempMatObject, unreal.MaterialExpressionTextureSample, -400, 0)
	Tex_Normal = MEL.create_material_expression(tempMatObject, unreal.MaterialExpressionTextureSample, -400, 300)
	Tex_OccRoughMetallic = MEL.create_material_expression(tempMatObject, unreal.MaterialExpressionTextureSample, -400, 600)	
	# Connect sampler Nodes to material 
	MEL.connect_material_property(Tex_BaseColor, "RGB", unreal.MaterialProperty.MP_BASE_COLOR)
	MEL.connect_material_property(Tex_Normal, "RGB", unreal.MaterialProperty.MP_NORMAL)
	# In this case different color channels represent properties:  Red = Occlusion, Green = Roughness, Blue = Metallic
	# Properties from unreal.MaterialProperty: 
	# 'MP_AMBIENT_OCCLUSION', 'MP_ANISOTROPY', 'MP_BASE_COLOR', 'MP_EMISSIVE_COLOR', 'MP_METALLIC', 'MP_NORMAL', 'MP_OPACITY', 'MP_OPACITY_MASK', 'MP_REFRACTION', 'MP_ROUGHNESS', 'MP_SPECULAR', 'MP_SUBSURFACE_COLOR', 'MP_TANGENT'
	MEL.connect_material_property(Tex_OccRoughMetallic, "R", unreal.MaterialProperty.MP_AMBIENT_OCCLUSION)
	MEL.connect_material_property(Tex_OccRoughMetallic, "G", unreal.MaterialProperty.MP_ROUGHNESS)
	MEL.connect_material_property(Tex_OccRoughMetallic, "B", unreal.MaterialProperty.MP_METALLIC)	
	# Set Texture Sample nodes to UDIM texture reference
	Tex_BaseColor.texture = UDIM_Base_Color
	Tex_Normal.texture = UDIM_Normal
	Tex_OccRoughMetallic.texture = UDIM_Occlusion_Roughness_Metallic	
	# Set sampler node type for Virtual Color / UDIM
	# Properties from unreal.MaterialSamplerType: 
	# SAMPLERTYPE_ALPHA', 'SAMPLERTYPE_COLOR', 'SAMPLERTYPE_DATA', 'SAMPLERTYPE_DISTANCE_FIELD_FONT', 'SAMPLERTYPE_EXTERNAL', 'SAMPLERTYPE_GRAYSCALE', 'SAMPLERTYPE_LINEAR_COLOR', 'SAMPLERTYPE_LINEAR_GRAYSCALE', 'SAMPLERTYPE_MASKS', 'SAMPLERTYPE_NORMAL', 'SAMPLERTYPE_VIRTUAL_ALPHA', 'SAMPLERTYPE_VIRTUAL_COLOR', 'SAMPLERTYPE_VIRTUAL_GRAYSCALE', 'SAMPLERTYPE_VIRTUAL_LINEAR_COLOR', 'SAMPLERTYPE_VIRTUAL_LINEAR_GRAYSCALE', 'SAMPLERTYPE_VIRTUAL_MASKS', SAMPLERTYPE_VIRTUAL_NORMAL
	Tex_BaseColor.set_editor_property("SamplerType", unreal.MaterialSamplerType.SAMPLERTYPE_VIRTUAL_COLOR)
	Tex_Normal.set_editor_property("SamplerType", unreal.MaterialSamplerType.SAMPLERTYPE_VIRTUAL_NORMAL)
	Tex_OccRoughMetallic.set_editor_property("SamplerType", unreal.MaterialSamplerType.SAMPLERTYPE_VIRTUAL_COLOR)

	return

# Static mesh name convention in this project is 'Mesh_Yourname.fbx'
def assignMaterial(myStaticMeshName, myMaterialName): # Assumes filename input with no extension
	if myStaticMeshName is not None: # Basic check in case of errors
		# Get imported mesh reference
		myAsset = unreal.load_asset(importDestPath + '/' + myStaticMeshName)
		# Get created material reference
		myMatToAssign = unreal.load_asset(importDestPath + '/' + myMaterialName)
		# Set material in index slot zero
		myAsset.set_material(0, myMatToAssign)
	
	return

# Debug - using warning color to highlight output for visibility in the UE5 Log Window
unreal.log_warning("----------[ TallTim's Automagic Asset Import & Material Wiring Utility ]---------- \n")

# Store results of scanned directories
scannedFileNames = readProjectAssets(myProjectPath)

# Do the import from your scanned project root folder
importAssets(scannedFileNames) # UE5 seems to handle it fine, if you encounter problems, maybe use threading/wait methods or time.sleep

# Iterate through our fbx list to assign materials
# Note - when you are testing using a limited set, this needs to use test_FBXList
# I could've wrapped this in a IF statement, but honestly why duplicate code for that.. one line change is easier

# Below for normal processing
for meshName in FBXList:
# Below for testing using a limited defined set of files
#for meshName in test_FBXList:
	if meshName is not None: # Basic check in case of errors
		# Strip everything except the actual 'Mesh_<fbx mesh name>' in the list
		# Split out the path slashes first
		pathSplitRaw = meshName.split('\\')
		# Return last element which is meshfilename.fbx
		fileNameRaw = pathSplitRaw[-1]
		# Split out the <filename>.<fbx> to get the asset name
		splitDot = fileNameRaw.split('.')		
		fbxAssetName = splitDot[0]
		# Strip out the 'Mesh_' prefix on the fbx filename -- Its assumed all fbx meshes are named this way
		splitMeshPrefix = fbxAssetName.split('_')
		# Add our material prefix -- If you don't like my naming conventions, feel free to change it - just catch it in the other functions
		textureAssetName = 'Mat_' + splitMeshPrefix[1]
		# Create our blank material with the same asset name
		myPath, myName = createBlankMaterial(textureAssetName) # The returned values were for debugging, redact if needed
		# Wire up the materials with our imported UDIM textures - This is easily changed to use regular 2D Texture types, see function
		wireUpTextures(textureAssetName)
		# Assign wired material to the imported fbx asset
		assignMaterial(fbxAssetName, textureAssetName)
Edited by TallTim - Nov. 13, 2023 18:38:04
User Avatar
Member
27 posts
Joined: May 2022
Offline
Might as well keep it all here. Code snippet to show another method to import, which may be helpful because it seems when you stuff a big list down the throat of the prior implementation it can miss things - which I'm guessing is just a thread/throttling problem.

	for pathItem in myMeshList:
		# Clear list every iteration
		extractMeshList = []
		# Set properties
		task.set_editor_property('filename', pathItem) # Source filepath
		task.set_editor_property('destination_path', extractDestPath(pathItem)) # Update dest path
		task.set_editor_property('automated', True)
		task.set_editor_property('replace_existing', True)
		meshTasks.append(task) # Shove task into list
		# Do import per iteration - testing doing batched again
		AT.import_asset_tasks(meshTasks) # Doing this here in the loop 'throttles' things.

It seems by batching single tasks this way it behaves, which allows me to mirror my project folder structure directly into UE5.

I'm working on scene export now from Houdini - Here's a python tool that grabs geometry nodes and does some property queries before writing to a CSV.

# --- 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
    # Get the "rop_fbx1" node of node paths
    fbxNode = hou.node(myNodePath + '/rop_fbx1')
    fbxFileName = fbxNode.parm('sopoutput').eval()
    #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
    #print("Pos: " + str(tempObjPos).split(','))
    # Split vector into floats for X, Y, Z
    vecPosFloats = vectorToFloats(tempObjPos)
    vecRotFloats = vectorToFloats(tempObjRot)
    vecScaleFloats = vectorToFloats(tempObjScale)
    # Debug
    #print(str(vecPosFloats))
    # Populate list - name, position, rotation, scaling
    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('_')
    
    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 = '<YourFullPathHere>/ExportedCSV/'
# 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)

It uses the name of the .HIP file you're working on as the filename of the csv, and in this case it uses the ROP FBX output filename to identify what mesh it is using for the position/rotation/scaling output. The geometry nodes have a '_UEA' suffix in their name so my exporter knows which are intended for writing to the CSV.

This allows for testing using other objects and whatnot without pushing it all into the file by default.

Example:

----------[ TallTim - CSV To Unreal Export Tool at 2023-11-17 10:27:13.980689 ]---------

Output fbx file prefix is: Mesh_BasicChamferedCube02

CSV Row for Mesh:

['Mesh_BasicChamferedCube02', [0.865, 0.0, 0.857], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]

All of this could be easily changed as you like. Just throw this into a shelf tool. To do so, a quick method is to Right-click on the toolbar, and select "New Tool", you can then go to the "Script" tab and start working with python.

If you want to set a nice icon for your shelf tool, they're stored here:
C:\Program Files\Side Effects Software\Houdini <your version>\houdini\config\Icons
You may need admin rights to save things there, but it will be available once you do. You can see the log output if you go down to the tabs after "Scene View", etc.. there's a small '+', click that to add a Python Shell. (Or use the technical desktop I suppose.)

Hope that helps someone!
Edited by TallTim - Nov. 19, 2023 10:51:46
User Avatar
Member
27 posts
Joined: May 2022
Offline
More progress, had to share!

Using the CSV Shelf Tool I described above, I wrote an importer script that lives in a Unreal Engine Blueprint (a simple thing that just executes when you select the scripted action - see above how to set that up).

Code is pretty well documented, but note that exporting/saving your .fbx from Houdini you should use "Y-Up Right Handed", and I had "Convert to specified axis system" and "Convert Units" checked as well.

Mesh spawner/properties script:

##  ______      ___________          _      
## /_  __/___ _/ / /_  __(_)___ ___ ( )_____
##  / / / __ `/ / / / / / / __ `__ \|// ___/
## / / / /_/ / / / / / / / / / / / / (__  ) 
##/_/  \__,_/_/_/ /_/ /_/_/ /_/ /_/ /____/  
##                                          
## 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.)

# ---- 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 = '<Path To Exported Houdini CSV File>'
myCSVFile = '<Filename>.csv' # Would be better to have these exposed as inputs in UE, but haven't done that yet...
myProjectPath = '<Your path here>' # Root path to scan for meshes

# Get directory contents
files = os.listdir(myDataPath)

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")
	#Texture_Assets = assetListRaw.rglob("*_DefaultMaterial_*.1001.*") # Only returns the 'head' of UDIM texture sets
	# Convert from rglob to set - might be redundant, but whatever, I need to get some work done lol
	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 In UE Content Browser>"
	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

# 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 for testing, etc
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())

# Now we filter the data and get it into shape for applying to imported assets
# Not processing anything for the marker in initial testing, I'll handle that later...

tempObjList = []
tempPropertyList = []
assetMeshPathList = []
# 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 != "Marker": # Filtering out a "Marker" object since I haven't fully implemented this yet
		tempObjList.append(tempObjName)

df_SceneList.set_index("ObjName", inplace = True) # Set index to object names

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)

# 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)))

def stringToList(myString): # Converts exported strings to floats - format '[x, y, z]'
	# 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

# 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) # This is the path in the UE Content Browser
	assetPrefix = meshFilePrefixList[idx] # Get meshfile name and add it to path
	sceneCheckFlag = checkSceneList(assetPrefix) # Checks if asset is in the scene dataframe
	# 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:
		finalLoadAssetPath = tempLoadAssetPath + '.' + assetPrefix
		tempObjRef = unreal.load_asset(finalLoadAssetPath) # Assign reference - path.meshfileprefix
		# Debug
		unreal.log_warning("Asset to set parameters is: " + assetPrefix)

		# Assign lists for Pos,Rot,Scale vectors
		posListRaw = df_SceneList.loc[assetPrefix, "Position"]
		rotListRaw = df_SceneList.loc[assetPrefix, "Rotation"]
		scaleListRaw = df_SceneList.loc[assetPrefix, "Scale"]
		# Process strings into float-casted lists
		posList = stringToList(posListRaw)
		rotList = stringToList(rotListRaw)
		scaleList = stringToList(scaleListRaw)
		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)

Here's the test scene in Houdini:



Here's the test scene in Unreal Engine 5.3 after using the exported .csv and the script above:



Important Note -- You MUST do any Position/Rotation/Scaling at the /obj level - the way the .csv exporter is coded it will not pick up any changes you make to the SOP internally, such as a transform node in your tree. Context matters!

I thought for a while when researching this that I'd have to do some "bake pivot" stuff into uv maps like some other techniques, but thankfully I didn't have to go down that convoluted rabbit hole. I'll do some more testing, but it is looking good so far.

Enjoy!

Feel free to use, just drop me a name credit somewhere, thanks.
Edited by TallTim - Nov. 20, 2023 13:22:48
User Avatar
Member
27 posts
Joined: May 2022
Offline
Another update -- Here's the full asset importer script that now supports duplicate objects in the exported scene file. I know, I should've had that in there from the start but I was busy putting out fires in other parts of the code. Anyway, this allows for referencing an imported mesh more than once to spawn it in your scene.

Working on more, I'll post when I have more progress. Cheers!

##  ______      ___________          _      
## /_  __/___ _/ / /_  __(_)___ ___ ( )_____
##  / / / __ `/ / / / / / / __ `__ \|// ___/
## / / / /_/ / / / / / / / / / / / / (__  ) 
##/_/  \__,_/_/_/ /_/ /_/_/ /_/ /_/ /____/  
##                                          
## 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)

Edit -- I'm going to continue all this pipeline stuff at my site if you're interested in more here. [solusmundi.com] Cheers!
Edited by TallTim - Dec. 14, 2023 22:18:07
  • Quick Links