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.
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:
= line.strip()
name = f'https://example.com/{name}'
url = requests.get(url, stream=True)
res if res.status_code == 200:
with open(f'names/{name}', 'wb') as imgfile:
shutil.copyfileobj(res.raw, imgfile)print(f'successfully downloaded {name}')
else:
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.
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:
True)
slow_task.make_dialog(for line in lines:
...
We can let users cancel the in-progress action by adding a simple
break
.
if slow_task.should_cancel():
break
Tallying progress is also super simple.
1) slow_task.enter_progress_frame(
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:
= f.readlines()
lines with unreal.ScopedSlowTask(len(lines), 'Adding images') as slow_task:
True)
slow_task.make_dialog(for line in lines:
= line.strip()
line ...
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:
= f.readlines()
lines with unreal.ScopedSlowTask(len(lines), 'Adding images') as slow_task:
True)
slow_task.make_dialog(return [line.strip() for line in lines]
def __exit__(self, *args):
pass
Now, our usage is much cleaner:
with AddImages('names.txt') as lines:
for line in lines:
...
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.
= unreal.AssetRegistryHelpers.get_asset_registry()
asset_reg = asset_reg.get_asset_by_object_path('/Game/StarterContent/Materials/M_Metal_Brushed_Nickel.M_Metal_Brushed_Nickel').get_asset() nickel
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.
'/Game/materials/image_Mat')
unreal.EditorAssetLibrary.find_asset_data(
'/Engine/BasicShapes/Plane') unreal.EditorAssetLibrary().load_asset(
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.
= unreal.Vector(-650, -976, 180)
location = unreal.EditorLevelLibrary().spawn_actor_from_object(plane, location, rotation=[0, -180, -270])
mesh 0, material)
mesh.static_mesh_component.set_material(= unreal.EditorLevelLibrary().spawn_actor_from_class(unreal.Text3DActor, location, rotation=[0, 90, 0]) text
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.
= 'my text'
text.text3d_component.text
= 15
textinst.text3d_component.extrude = 2
text.text3d_component.bevel = 1
textinst.text3d_component.bevel_segments
= unreal.Text3DHorizontalTextAlignment.CENTER
text.text3d_component.horizontal_alignment
= nickel
text.text3d_component.front_material = nickel
text.text3d_component.back_material = nickel
text.text3d_component.bevel_material
0.3, 0.3, 0.3)) text.root_component.set_world_scale3d(unreal.Vector(
Overall, this was easy enough and saved a ton of work over manually placing 500 images that it seemed well worthwhile to me.
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.
= unreal.EditorAssetLibrary().load_asset(f'/Game/materials/{name}')
texture = unreal.AssetToolsHelpers.get_asset_tools().create_asset(f'{name}_Mat', f'/Game/materials', unreal.Material, unreal.MaterialFactoryNew()) material
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.)