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

SoftwareUnit SystemLength Unit
BlenderNone (Blender Units), MetricMeter (apply a 0.01 multiplier to convert to centimeters)
Unreal Engine 5MetricCentimeter (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:

Coffee Mug at Scale

*Coffee mug imported into Unreal at the correct scale (left) and incorrect scale (right).


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:

  1. Get a valid filepath for a target folder - to start things out, this could be the Content\ folder of an Unreal project
  2. Append the name of the FBX file to the filepath
  3. Export and place the FBX in the target folder

Meaningful Options

I anticipated that a Blender user would want the option to:

  1. Set a custom FBX filename. If not specified, then the filename should be the .blend filename by default
  2. Set a valid subdirectory to send the FBX file to. For example, send a Suzanne.fbx to the Mesh\ subdirectory under the Content\ folder
  3. Apply a scale transform to the output FBX. This would be a 0.01 multiplier by default, unless changed
  4. 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:

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:

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:

Simple Custom Panel

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:

Python Tooltip in Blender

*Hovering over the Scale Transform Y attribute to view the user tooltip and Python tooltip.


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:

Selected Object Types

*Adjusted settings for Blender's built-in FBX Exporter.




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:

Installed Add-on and Minimal GUI

*Installed Add-on and Minimal GUI.


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:

Filepath Layout BeforeFilepath Layout After

*Before and after layout adjustments.


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:

Panel with Directory and Filename Edit 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:

Panel and Subpanels

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: