Tech Art Crash Course (Part II): Creating an FBX Exporter from Blender to Unreal Engine 5
This blog post covers the remainder of the work that I completed for a technical art course which ran from February 2024 to April 2024. The previous blog post can be found here. To skip to the demo video, click here.
Background
As the final weeks approached, I was given the choice of building either a Python tool, an Unreal Engine 5 Blueprint, a shader/material, or a Niagara visual effect. Since I was previously familiar with Python, and because I was not yet fully comfortable with Unreal Engine 5, I decided to opt for the Python tool.
After narrowing down my choices, I was given some suggestions as to what to build, such as an LOD generator, or a file log parser, or an FBX settings manager … and the list goes on. I went with the final suggestion, since I knew of the FBX file format from working with software such as Blender and Autodesk Maya.
Since I was more comfortable with Blender at that point in time, I decided to shift my focus to the former - I was to build an FBX exporter specifically for Blender (in the form of a Blender Add-on), with Unreal Engine 5 as the destination for an outbound FBX.
Unit Conversion
One important issue that I wanted to sort out was the unit system mismatch between Blender and Unreal Engine 5. In order for the FBX exporter to be effective, I decided that it should convert between units in addition to exporting the actual FBX.
I checked the Blender documentation and recalled that the default unit of measurement in Blender was one meter, assuming that the unit system setting under the Scene Properties tab is set to either “None” or “Metric”. I also recalled that the default unit of measurement in Unreal Engine 5 was one centimeter, assuming that Unreal’s unit system settings remain unchanged (should be metric):
Software | Unit System | Length Unit |
---|---|---|
Blender | None (Blender Units), Metric | Meter (apply a 0.01 multiplier to convert to centimeters) |
Unreal Engine 5 | Metric | Centimeter (the target unit) |
Modeling at Scale
Note that this conversion would be applicable under the assumption that a 3D modeler did NOT build an asset to the correct scale. As a somewhat contrived example, a modeler has NOT changed Blender’s scene length unit to centimeters OR the unit scale to 0.01 - in lieu of building a small object (such as a coffee mug) at a height of about 10 centimeters, the modeler has instead built the object at a height of about 10 meters:
The Proposal
I was granted the go-ahead to build my exporter, under the conditions that it was similar in complexity to the batch renamer from the previous assignment, and that it had a GUI with three or more meaningful options for the user to configure.
Requirements
It was imperative that the exporter could do at least the following:
- Get a valid filepath for a target folder - to start things out, this could be the
Content\
folder of an Unreal project - Append the name of the FBX file to the filepath
- Export and place the FBX in the target folder
Meaningful Options
I anticipated that a Blender user would want the option to:
- Set a custom FBX filename. If not specified, then the filename should be the
.blend
filename by default - Set a valid subdirectory to send the FBX file to. For example, send a
Suzanne.fbx
to theMesh\
subdirectory under theContent\
folder - Apply a scale transform to the output FBX. This would be a 0.01 multiplier by default, unless changed
- Select a mesh to export, or an armature (for animation), or both
For the last option, I went with the mesh and armature object types after looking into a video which covered ideal FBX export settings for Unreal Engine 5. The next section goes over these selections in further detail.
Research
Having never written a Blender Add-on before, I did a YouTube search on the topic. I found these videos to be hugely useful for initial setup:
- Create a Custom Blender Panel with Less Than 50 Lines of Python Code: https://www.youtube.com/watch?v=Qyy_6N3JV3k
- How to Install Addons in Blender: https://www.youtube.com/watch?v=vYh1qh9y1MI
I also searched for existing exporters from Blender to Unreal, as I was fairly sure that the unit system mismatch issue had already been addressed by many - however, I still wanted to meet the requirements that I set for my own project. I was able to find a Blender Add-on named Send to Unreal (maintained by Epic Games), as well as a video describing the difference between it and the built-in exporter:
- Send to Unreal: https://epicgamesext.github.io/BlenderTools/send2ue
- Send to Unreal VS FBX Export: https://www.youtube.com/watch?v=y51-tXjKEzc
Add-on Installation
I made sure that I could install and uninstall a Blender Add-on after following along with the first video (credits to Victor Stepanov). I downloaded the
example simple_custom_panel.py
file,
navigated to Blender’s Edit > Preferences
menu, clicked on the Add-ons
section and Community
tab, and clicked on the “Install” button to open a file browser and find
the Python file.
Then, I installed and activated the example Add-on:
Blender Python API
Continuing on from the Edit > Preferences
menu and Interface
section, I enabled a few developer-friendly features such as User Tooltips and
Python Tooltips.
Then, I navigated to the Properties
editor and Object
tab, and
hovered over the Scale Transform attributes of the active object (the default cube) to view the two tooltips:
I made sure that I could access the scale attribute listed in the Python tooltip, by navigating to the Python console (under Blender’s Scripting workspace tab), and then entering the following commands to halve the dimensions of the default cube:
c = bpy.data.objects['Cube']
c.scale = Vector((0.5, 0.5, 0.5))
The Built-in Exporter
I decided to try out the default exporter, to better understand the settings I needed to change for Unreal. First, I navigated to the File > Export > FBX
menu to open the built-in dialog,
and set the target folder to the Content\
folder of an Unreal Project. Then, I clicked the “Export FBX” button without adjusting the default settings.
This caused Unreal to show a warning on FBX import:
No smoothing group information was found in this FBX scene.
Afterwards, I made a few changes to the export settings (with help from this video which also covered ideal FBX export settings for Unreal). I checked Selected Objects, boxed in the list of possible object types to just the “Mesh” and/or “Armature”, and set the Smoothing type to “Face” instead of “Normals Only”. These new settings allowed Unreal to import my default cube without issues:
Implementation
First, I needed the all-important Python command which mapped to the export operation as shown above.
A search through the Blender Python API documentation brought up a function named bpy.ops.export_scene.fbx
,
with a long list of default arguments. For brevity, I have listed just the arguments which I found to be relevant to the project, over time:
bpy.ops.export_scene.fbx(filepath='', global_scale=1.0, apply_unit_scale=True, apply_scale_options='FBX_SCALE_NONE',
use_selection=False, object_types={'ARMATURE', 'CAMERA', 'EMPTY', 'LIGHT', 'MESH', 'OTHER'},
mesh_smooth_type='OFF')
Following the example code
from the first video, I set up the necessary code to add a panel
to the sidebar of the 3D viewport. I set up one button to execute export_scene.fbx
when clicked:
import bpy
bl_info = {
"name": "Export FBX From Blender to Unreal Engine 5 (.fbx)",
"category": "Import-Export",
}
class MainPanel(bpy.types.Panel):
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Export FBX"
bl_label = "Export FBX"
def draw(self, context):
self.layout.operator('export_scene.fbx', text='Run')
def register():
bpy.utils.register_class(MainPanel)
def unregister():
bpy.utils.unregister_class(MainPanel)
# Run the script directly from Blender's
# Text Editor without installing the add-on
if __name__ == "__main__":
register()
I made sure that I could install this Python file as a Blender Add-on, and that the panel was visible. I confirmed that clicking on the “Run” button opened a dialog for the built-in exporter:
From here, I needed to pass some input arguments, such as the filepath, to export_scene.fbx
. I referred again to the documentation
and decided to define a custom operator in order to handle additional logic.
The Export Operator
I defined a new subclass of bpy.types.Operator
, and prepared to override its execute()
method. I registered my new Operator
subclass with
Blender, moved the text label into the bl_label
property, and set a unique bl_idname
so that my Panel
subclass would be able to discover it:
class ExportOperator(bpy.types.Operator):
'''Export the FBX'''
bl_idname = "export_scene.custom_fbx"
bl_label = "Run"
def execute(self, context):
return {'FINISHED'}
class MainPanel(bpy.types.Panel):
...
def draw(self, context):
self.layout.operator('export_scene.custom_fbx')
For the execute()
method body, I needed to name the FBX after the currently opened Blender file. I extracted the filename
with the help of bpy.data.filepath
and bpy.path.basename
. Then, I hardcoded a filepath with
the filename and .fbx
extension, and sent it to export_scene.fbx
as its first input argument:
class ExportOperator(bpy.types.Operator):
...
def execute(self, context):
bpath = bpy.data.filepath
[stem, ext] = os.path.splitext(bpy.path.basename(bpath))
filepath = os.path.join('C:\\', 'Unreal Projects', 'MyProject', 'Content', stem + '.fbx')
bpy.ops.export_scene.fbx(filepath=filepath)
self.report({'INFO'}, f"Export {filepath}")
return {'FINISHED'}
Once the custom operator could be called without issue, I revisited the docs to learn how to pass in a user-defined target folder,
from the GUI to the inner export_scene.fbx
.
User-Defined Filepath
I eventually landed on this page, which covers custom properties in Blender. Shortly afterwards,
I attached one StringProperty
named project_dir
to the current
scene, to hold onto the value for a user-defined target folder:
bpy.types.Scene.project_dir = bpy.props.StringProperty(
name="Project Folder",
description="Unreal Engine 5 project folder",
)
I appended this StringProperty
to my panel layout in the 3D viewport:
placeholder = os.path.join('C:\\', 'Unreal Projects', 'MyProject', 'Content')
self.layout.prop(context.scene, "project_dir", placeholder=placeholder)
And replaced the hardcoded filepath in my operator with the new value:
filepath = os.path.join(context.scene.project_dir, stem + '.fbx')
The File Select Operator
Still, I needed some way to open a file browser and assign the resulting filepath to the project_dir
, to make the Blender Add-on much easier to use.
I registered a second operator to do this:
class ProjectPathOperator(bpy.types.Operator):
'''Set the Unreal Engine 5 Project Folder'''
bl_idname = 'export_scene.project_dir'
bl_label = "Add"
# select directories
directory: bpy.props.StringProperty()
# show only directories
filter_folder: bpy.props.BoolProperty(
default=True,
options={"HIDDEN"}
)
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def execute(self, context):
context.scene.project_dir = self.directory
return {'FINISHED'}
And appended this operator to my panel layout in the 3D viewport:
self.layout.operator('export_scene.project_dir')
With both an editable filepath and a file browser, I made some tweaks to group corresponding widgets closely together, under a shared label and sub-layout:
Subdirectory and Filename
I wrapped up the rest of the pathname-related tasks by repeating the steps above - I added two additional instances of StringProperty
,
and made sure that the export operator could append a subdirectory and filename to the existing filepath. I decided to display the subdirectory
in particular as a relative path:
class ProjectSubPathOperator(bpy.types.Operator):
'''Set the Unreal Engine 5 Project Subdirectory'''
bl_idname = 'export_scene.project_subdir'
bl_label = "Add"
...
def execute(self, context):
commonpath = os.path.commonpath(
[context.scene.project_dir, self.directory]) + os.sep
# display relative directory
rel_project_subdir = self.directory.replace(commonpath, '')
context.scene.project_subdir = rel_project_subdir
return {'FINISHED'}
And, I updated the panel layout with the new fields:
Dynamic Property Group
To keep things organized for later changes, I moved the StringProperty
instances into a
PropertyGroup
- I registered my new class, and attached a
PointerProperty
to the current scene
to maintain a reference to the PropertyGroup
:
class ExportPropertyGroup(bpy.types.PropertyGroup):
project_dir: bpy.props.StringProperty(
name="Project Folder",
description="Unreal Engine 5 project folder",
)
...
# subdirectory and filename properties
def register():
...
bpy.utils.register_class(ExportPropertyGroup)
bpy.types.Scene.io_ue5_fbx = bpy.props.PointerProperty(type=ExportPropertyGroup)
def unregister():
...
bpy.utils.unregister_class(ExportPropertyGroup)
del bpy.types.Scene.io_ue5_fbx # cleanup
I also made sure to update StringProperty
access throughout my script, now that the PropertyGroup
had ownership:
class ExportOperator(bpy.types.Operator):
...
def execute(self, context):
io_ue5_fbx = context.scene.io_ue5_fbx
project_dir = io_ue5_fbx.project_dir
project_subdir = io_ue5_fbx.project_subdir
stem = io_ue5_fbx.filename
...
filepath = os.path.join(project_dir, project_subdir, stem + '.fbx')
class MainPanel(bpy.types.Panel):
...
def draw(self, context):
placeholder = os.path.join('C:\\', 'Unreal Projects', 'MyProject', 'Content')
self.layout.prop(context.scene.io_ue5_fbx, "project_dir", placeholder=placeholder)
...
Scale Transform
For the scale transform factor, I added a FloatProperty
to my PropertyGroup
to hold a numerical value, with 0.01 as the stated default:
class ExportPropertyGroup(bpy.types.PropertyGroup):
scale: bpy.props.FloatProperty(
name="Scale",
description="Scale Factor",
precision=2,
default=0.01,
soft_min=0,
soft_max=10,
)
...
# previously defined properties
Selected Objects
For the selected object types, I added two instances of BoolProperty
to keep track of whether or not to export a mesh, an armature, or both:
class ExportPropertyGroup(bpy.types.PropertyGroup):
mesh: bpy.props.BoolProperty(
name="Mesh",
description="Export the selected mesh",
default=False,
)
armature: bpy.props.BoolProperty(
name="Armature",
description="Export the selected armature",
default=False,
)
...
# previously defined properties
Smoothing Type
For the smoothing information, I created an EnumProperty
to stand in for one of three separate options, and set the default option to “Face”:
class ExportPropertyGroup(bpy.types.PropertyGroup):
smoothing: bpy.props.EnumProperty(
name="Smoothing",
description="Export smoothing information",
default="FACE",
items=[
("FACE", "Face (Recommended)", "Write face smoothing"),
("EDGE", "Edge", "Write edge smoothing"),
("OFF", "Normals Only", "Export only normals instead of writing edge or face smoothing data"),
],
)
...
# previously defined properties
Panel Layout
I added one more operator to my panel labeled “Reset to Recommended Defaults” to allow for reverting the filename, selected object types, scale factor, and smoothing options to their default values.
Then, I finalized the overall layout by moving properties/operators related to filepath configuration and export settings under a “Settings” subpanel, and kept the remaining operators under an “Export” subpanel:
Validation
To show my Add-on under the conditions that the selected objects
in the current scene included at least one mesh and/or armature, I decided to monitor the state of the selected objects with help from
bpy.types.Panel.poll
and
bpy.context.selected_objects
:
class MainPanel(bpy.types.Panel):
...
@classmethod
def poll(cls, context):
'''
Decide whether or not to show the tool based on context
'''
obj_types = ['MESH', 'ARMATURE']
found = False
# check selected objects
for obj in context.selected_objects
if (obj.type in obj_types):
found = True
break
return found
...
I also made sure to enable the “Run” button, given a valid filepath. In all other cases, I decided to grey out the button:
def update_project_dir(self, context):
if (not os.path.isdir(self.project_dir)):
print(f"Invalid filepath: {self.project_dir}")
class ExportPropertyGroup(bpy.types.PropertyGroup):
project_dir: StringProperty(
name="Project Folder",
description="Unreal Engine 5 project folder",
update=update_project_dir
)
...
# previously defined properties
class ExportPanel(bpy.types.Panel):
...
def draw(self, context):
...
row = self.layout.row()
row.operator('export_scene.custom_fbx')
if (not os.path.isdir(context.scene.io_ue5_fbx.project_dir)):
row.enabled = False
Export to Unreal
Finally, I passed the user-defined arguments from the GUI into Blender’s export_scene.fbx()
:
class ExportOperator(bpy.types.Operator):
'''Export the FBX'''
bl_idname = "export_scene.custom_fbx"
bl_label = "Run"
def execute(self, context):
io_ue5_fbx = context.scene.io_ue5_fbx
# filepath ------------------------------------------- #
project_dir = io_ue5_fbx.project_dir
project_subdir = io_ue5_fbx.project_subdir
if (io_ue5_fbx.filename):
stem = io_ue5_fbx.filename
else:
bpath = bpy.data.filepath
[stem, _] = os.path.splitext(bpy.path.basename(bpath))
filepath = os.path.join(project_dir, project_subdir, stem + '.fbx')
# rest of the settings ------------------------------- #
global_scale = io_ue5_fbx.scale
apply_unit_scale = True
apply_scale_options = 'FBX_SCALE_NONE'
use_selection = True
object_types = set()
if (io_ue5_fbx.mesh):
object_types.add('MESH')
if (io_ue5_fbx.armature):
object_types.add('ARMATURE')
mesh_smooth_type = io_ue5_fbx.smoothing
# export ---------------------------------------------- #
bpy.ops.export_scene.fbx(filepath=filepath,
global_scale=global_scale,
apply_unit_scale=apply_unit_scale,
apply_scale_options=apply_scale_options,
use_selection=use_selection,
object_types=object_types,
mesh_smooth_type=mesh_smooth_type)
self.report({'INFO'}, f"Export {filepath}")
return {'FINISHED'}
Demo
Below are some screen recordings of the completed FBX Exporter Blender Add-on, with a free 3D model from CGTrader.
Since the 3D model did not come with a rig, I made a quick character rig to test out the Blender Armature: