mitchell vitez blog music art media dark mode

Scripting Unreal Engine

Unreal supports scripting the editor in Python. This isn’t runtime game code—that’s written in either C++ or with the visual-nodes-based Blueprints. However, it’s very useful for adding a large amount of content to a level relatively quickly.

The goal for this script is to add ~500 images to a museum-like hallway. Each one should have a 3D text component attached, describing the image.

Downloading the source images

We’ll use requests to access the network, shutil to save images locally, and of course unreal to add them as actors in our level.

import requests
import shutil
import unreal

A common pattern for many of the small Python scripts I’ve written recently is they read the lines in a file, access the network to grab something related to each line, and save it. This is also the first step of the process for our script here, since the images are saved remotely.

with open('names.txt') as f:
    for line in f:
        name = line.strip()
        url = f'{name}'
        res = requests.get(url, stream=True)
        if res.status_code == 200:
            with open(f'names/{name}', 'wb') as imgfile:
                shutil.copyfileobj(res.raw, imgfile)
                print(f'successfully downloaded {name}')
            print('FAILED on {name}')

So far, this can be run completely outside the context of Unreal. But it does set us up with a local folder full of images we can start importing as assets in the game.

Making our script pleasant to use

Unreal provides a few nice facilities for interacting with scripts in the editor. The first is transactions:

with unreal.ScopedEditorTransaction('Add images') as transaction:

Putting the rest of our code inside this with means that even though we’re adding hundreds of image with 3D text components attached, our entire script can be undone with one “undo” command. I highly recommend keeping Unreal’s edit history clean by using this.

Another nice feature that with brings us is the ability to see an in-editor dialog that counts up to the total number of tasks we want to do—in this case just the number of lines.

with unreal.ScopedSlowTask(len(lines), 'Adding images') as slow_task:
    for line in lines:

We can let users cancel the in-progress action by adding a simple break.

if slow_task.should_cancel():

Tallying progress is also super simple.


However, because I want to read from a file, and to run my task in a transaction, and to scope that slow task, I have a triply-nested block of with statements.

with unreal.ScopedEditorTransaction('Add images') as transaction:
  with open('names.txt') as f:
      lines = f.readlines()
      with unreal.ScopedSlowTask(len(lines), 'Adding images') as slow_task:
          for line in lines:
              line = line.strip()

This can get rather unwieldy. I might recommend combining these into one larger with via creation of an object with __enter__ (for startup) and __exit__ (for cleanup) defined. It’ll look roughly like this nested with in __enter__ (but with more state management):

class AddImages(object):
    def __init__(self, file_name):
        self.file_name = file_name

    def __enter__(self):
        with unreal.ScopedEditorTransaction('Add images') as transaction:
          with open(self.file_name) as f:
              lines = f.readlines()
              with unreal.ScopedSlowTask(len(lines), 'Adding images') as slow_task:
                  return [line.strip() for line in lines]

    def __exit__(self, *args):

Now, our usage is much cleaner:

with AddImages('names.txt') as lines:
    for line in lines:

Working with Unreal’s scripting API

The scripting API is pretty thorough, but not entirely pleasant to use. There are fairly often strange intermediate objects that have to be placated before you can get the asset you want. This example creates an asset registry, then finds an object by name, then gets an asset from that object.

asset_reg = unreal.AssetRegistryHelpers.get_asset_registry()
nickel = asset_reg.get_asset_by_object_path('/Game/StarterContent/Materials/M_Metal_Brushed_Nickel.M_Metal_Brushed_Nickel').get_asset()

This code is already fragile due to using the path string to find my material that might not exist, so I’m not sure what the point is of the registry. This is somewhat annoying relatively to other engines I’ve used. For example, in Godot you can trivially grab the player node with GDScript’s special dollar sign syntax: $Player, or look up an asset with res://.

However, there was another way to grab assets that seemed way simpler. Unfortunately I found it after finding the AssetRegistry. The docs say “The AssetRegistryHelpers class has more complex utilities”, which seems about right.



Besides this, I mostly found scripting Unreal was pretty nice. The docs had most of what I wanted to do explained pretty well, and only felt lackluster in a few other places (namely, creating nodes for materials).

Basic types like vectors made setting certain component properties fairly convenient. The ordering of the rotation list didn’t seem to match the one in-editor? But, it was easy to fix that with some trial and error.

location = unreal.Vector(-650, -976, 180)
mesh = unreal.EditorLevelLibrary().spawn_actor_from_object(plane, location, rotation=[0, -180, -270])
mesh.static_mesh_component.set_material(0, material)
text = unreal.EditorLevelLibrary().spawn_actor_from_class(unreal.Text3DActor, location, rotation=[0, 90, 0])

Setting properties on a component was dead simple. I was also pleasantly surprised that a few enums were in use to fill in certain dropdowns. The root_component made adjusting the parent node easy too.

text.text3d_component.text = 'my text'

textinst.text3d_component.extrude = 15
text.text3d_component.bevel = 2
textinst.text3d_component.bevel_segments = 1

text.text3d_component.horizontal_alignment = unreal.Text3DHorizontalTextAlignment.CENTER

text.text3d_component.front_material = nickel
text.text3d_component.back_material = nickel
text.text3d_component.bevel_material = nickel

text.root_component.set_world_scale3d(unreal.Vector(0.3, 0.3, 0.3))

Overall, this was easy enough and saved a ton of work over manually placing 500 images that it seemed well worthwhile to me.

Material node creation

The part of this scripting that took me the longest was finding up the updated (as of Unreal 5.1) way to create materials. There’s an “expression” language, where you connect properties of nodes to other nodes. This is a lot like the visual dragging of connections between nodes in a Blueprint, but wasn’t easy to find (I ended up mostly copying from a blog post, then adjusting).

Creating a material itself isn’t too bad—there’s a MaterialFactoryNew object that helps out.

texture = unreal.EditorAssetLibrary().load_asset(f'/Game/materials/{name}')
material = unreal.AssetToolsHelpers.get_asset_tools().create_asset(f'{name}_Mat', f'/Game/materials', unreal.Material, unreal.MaterialFactoryNew())

It took me too long to discover connect_material_property. However, now that I know about it, the connections aren’t too hard to form as long as nodes get built in the right order. I wish the “from output” argument were an enum rather than a string.

base_color = unreal.MaterialEditingLibrary.create_material_expression(material, unreal.MaterialExpressionTextureSample)
base_color.texture = texture
unreal.MaterialEditingLibrary.connect_material_property(base_color, 'RGB', unreal.MaterialProperty.MP_BASE_COLOR)
unreal.MaterialEditingLibrary.connect_material_property(base_color, 'A', unreal.MaterialProperty.MP_OPACITY)
unreal.MaterialEditingLibrary.connect_material_property(base_color, 'A', unreal.MaterialProperty.MP_OPACITY_MASK)
material.set_editor_property('blend_mode', unreal.BlendMode.BLEND_MASKED)

I think my main hiccups are fairly excusable—not finding the MaterialEditingLibrary or the EditorAssetLibrary quickly enough. I got stuck on brittler, more cantankerous APIs for a while because of that. Overall though, scripting a game engine is super fun. It makes me wish more programs were metaprogrammable in this kind of way. (The other example I know of that does this well is Blender with its Python scripting.)