UE uses C++ as a compiled language, which means it becomes binary after compilation, and players can only update the game by reinstalling it. However, in game development, there’s often an urgent need for requirement adjustments and bug fixes, and frequently prompting players to update the app is unacceptable. Generally, game projects use Lua as the scripting language to transform the immutable C++ code at runtime into updatable Lua code.
Although UE does not officially support Lua, Tencent has open-sourced UnLua, which is being used in my current project. Over the past few days, I’ve organized some materials on UnLua (mainly the official documentation, issues, and presentation PPT) and combined it with my own experience from testing UnLua while writing a small demo, resulting in this programming guide for UE combined with UnLua. This article summarizes some basic methods and pitfalls for using UnLua to write business logic and will be updated continuously.
Additionally, Lua files can be packaged into Pak using my previously open-sourced tool: hxhb/HotPatcher. I’ve also modified a version based on UnLua, adding some extra optimizations. The source code integrates the Luasocket/Luapanda/
lpeg
/Sproto
/Luacrypt
libraries, and you can use LuaPanda for debugging. The GitHub address is: hxhb/debugable-unlua.
Reference Materials:
- UnLua_UE4 Lua Script Plugin PPT
- UnLua_Programming_Guide_EN
- Programming in Lua, 1st
- Tencent/UnLua/issues
UnLua Considerations
These are some potential pitfalls I’ve extracted from the UnLua official repository.
- Not all UFUNCTIONs are supported; only
BlueprintNativeEvent
/BlueprintImplementationEvent
/Replication Notify
/Animation Notify
/Input Event
are supported. - UnLua does not support multiple states, so running multiple clients in PIE mode will have issues. issues/78
Do not access Blueprint-defined structures in Lua. issues/119 / issues/40(New submissions are now supported)- Using
self.Super
in UnLua will not recursively traverse all base classes in the inheritance hierarchy. issues/131 - Non-dynamic delegates are not supported. issues/128
- You cannot directly export static members of a class, but you can encapsulate them in a static method. issues/22
- You cannot bind a Lua script to an object instance; the script specified in NewObject is bound to the UCLASS. issues/134
- The index rule for using
UE4.TArray
in UnLua starts from 1, different fromTArray
in the engine which starts from 0. issues/41 - Be aware that many functions exported to Lua have different names than those in Blueprints. For instance,
GetActorTransform
in Blueprints is actually used asGetTransform
in Lua, and the names used in Lua are the actual defined names in C++, not theDisplayName
.
Lua Code Hints
Exporting Symbols
UnLua provides support for exporting reflection symbols within the engine, and you can also statically export symbols from non-reflection classes. It provides a Commandlet
class called UUnLuaIntelliSenseCommandlet
to export these symbols.
Here’s a brief introduction to calling the Commandlet
:
1 | UE4Editor-cmd.exe PROJECT_PATH.uproject -run=COMMANDLET |
The usage of UnLua’s Commandlet is:
1 | D:\UnrealEngine\Epic\UE_4.23\Engine\Binaries\Win64\UE4Editor-cmd.exe C:\Users\imzlp\Documents\UnrealProjectSSD\MicroEnd_423\MicroEnd_423.uproject -run=UnLuaIntelliSense |
After execution, the directory UnLua/Interamate/IntelliSense
will be generated, which contains symbols exported to Lua by the engine.
Using VSCode
Install the Emmylua plugin in VSCode, and after installation, add the previously generated IntelliSense
directory to the vcode workspace.
Calling Parent Class Functions
In Unreal C++, when we’ve overridden a virtual function from a parent class, we can specify calling the parent class implementation through Super::
, but it’s different in Lua.
1 | self.Super.ReceiveBeginPlay(self) |
In Lua, self.Super
is used to call the parent class’s function.
Note: In UnLua, Super simply simulates the “inheritance” semantics; in the case of multi-layer inheritance, there will be issues. Super will not actively traverse down the inheritance hierarchy. The “parent class” is not the metatable returned by
Class()
, but is set on theSuper
field (the metatable is defined in the C++ code of the plugin, and this process has been solidified).
For calling the superclass's superclass
:
1 | a.lua |
This issue was excerpted from UnLua issues: Usage Issue of
Super
in Class
Calling Overridden Methods
Note: Lua and the original class do not have an inheritance relationship but rather an affiliation relationship. Lua depends on Blueprint or C++ classes. The functions of the Lua-overridden class effectively replace the implementation of the current class’s functions.
When overriding a UFUNCTION in a Lua subclass, you can call it using the following method, which is somewhat similar to Super
, but written as Overridden
:
1 | function BP_Game_C:ReceiveBeginPlay() |
Ensure to pass self
in, as failing to do so will cause a crash during execution due to parameter checks in UnLua. Calling UE functions in UnLua is akin to grabbing the raw pointer to a member function; you must manually pass this to it.
Calling UE C++ Functions
UnLua allows enabling or disabling the UE namespace during compilation (i.e., all UE functions require the UE4 prefix).
The calling method is:
1 | UE4.UKismetSystemLibrary.PrintString(self,"HelloWorld") |
The parameters match those of C++ calls (those with default parameters can also be omitted):
1 | UKismetSystemLibrary::PrintString(this, TEXT("Hello")) |
Overriding Functions with Multiple Return Values
Blueprints
The overridden Lua code:
1 | function LoadingMap_C:GetName(InString) |
C++
Since multiple return values in C++ are realized by passing reference parameters, it’s different in Lua.
For the following C++ function:
1 | // .h |
This function takes WorldContextObject
as a parameter and receives three types as reference parameters, returning a bool. How to receive these parameter values in Lua?
1 | local ret1, ret2, ret3, ret4 = UE4.UFlibGameFrameworkStatics.LuaGetMultiReturnExample(self, nil, nil, nil) |
This local ret1, ret2 = func()
syntax follows Lua’s rules; you can refer to Programming in Lua, 4th edition, Chapter Six.
Note: The order for receiving reference parameter return values and the actual function return value is: ret1, ret2, and ret3 are the reference parameters, while the final function return
bool
is ret4.
Notes on Non-const Reference Parameters as Return Values
Note: When calling UFUNCTION functions, non-const reference parameters can be ignored, but this does not apply to statically exported functions because UnLua performs a check on the number of parameters for static exports.
Additionally, using references as return parameters requires consideration of the following two scenarios:
- Non-const reference as pure output
1 | void GetPlayerBaseInfo(int32 &Level, float &Health, FString &Name) |
In this case, the return value and input value have no relationship. In Lua, you can use:
1 | local level, heath, name = self:GetPlayerBaseInfo(0, 0, ""); |
- Non-const reference parameter used as both input and output
1 | void GetPlayerBaseInfo(int32 &Level, float &Health, FString &Name) |
In this scenario, the return value and input are directly related. Therefore, you cannot use it like in case 1:
1 | local level, heath, name |
In this case, when calling Lua, both passed-in parameters and returned parameters must be specified; otherwise, normal behavior won’t occur as the values of level, heath, and name
passed won’t change like C++ reference passing.
Thus, how to call the function always depends on how it is written.
This is also mentioned in UnLua’s issues: issues/25
Checking Interface Inheritance
In C++, you can use UKismetSystemLibrary::DoesImplementInterface
, and the same applies in Lua, but with a difference in the type of the interface passed in:
1 | local CubeClass = UE4.UClass.Load("/Game/Cube_Blueprint.Cube_Blueprint") |
You need to use the UE4.U*Interface
format.
Obtaining TScriptInterface
Interface Objects
When obtaining an interface in C++, the obtained type is TScriptInterface<>
. Originally, I thought that I’d have to export TScriptInterface
to access the interface. However, in UnLua, you can directly get TScriptInterface<>
just like a normal UObject object.
For example, the following function:
1 | UFUNCTION(BlueprintCallable, Category = "GameCore|Flib|GameFrameworkStatics", meta = (CallableWithoutWorldContext, WorldContext = "WorldContextObject")) |
Calling it in Lua:
1 | local GameInstance = UE4.UFlibGameFrameworkStatics.GetNetGameInstance(self); |
The obtained type in C++ is TScriptInterface<>
, but in Lua, it becomes the UObject of that interface.
Calling Member Functions
Having obtained the TScriptInterface
interface (which is actually the UObject of that interface in Lua), there are three methods to call interface methods:
1 | // This class does not need to be modified. |
Calling Functions via Objects
- You can obtain the object that implements the interface and then call the function through that object (using Lua’s
:
operator):
1 | local GameInstance = UE4.UflibGameFrameworkStatics.GetNetGameInstance(self); |
Specifying Class and Function Name
- You can directly call by specifying the type name that implements the interface, much like a function pointer, passing the calling object’s instance as a parameter:
1 | local GameInstance = UE4.UflibGameFrameworkStatics.GetNetGameInstance(self); |
Specifying Interface Class and Function Name
- You can also call through the interface type (as interfaces are UClass and their functions are also marked as UFUNCTION):
1 | local GameInstance = UE4.UflibGameFrameworkStatics.GetNetGameInstance(self); |
Obtaining UClass
To obtain a UClass in Lua for object creation, the method is:
1 | local uclass = UE4.UClass.Load("/Game/Core/Blueprints/AI/BP_AICharacter.BP_AICharacter_C") |
The Load path corresponds to the PackagePath
of that class.
For example, loading a UMG class in Lua and creating it to add to the viewport:
1 | function LoadingMap_C:ReceiveBeginPlay() |
Note: The official implementation of UE4.UClass.Load
in UnLua is defaulted to only create blueprint classes. For specifics, see the implementation in LuaLib_Class.cpp
where UClass_Load
is implemented.
You can modify it to support C++ classes:
1 | int32 UClass_Load(lua_State *L) |
The path for C++ classes is:
1 | /Script/GWorld.GWorldGameEngine |
It starts with /Script
, followed by the module name to which the C++ class belongs, and after the .
is the class name (excluding U
/A
, etc.).
LoadObject
Load resources into memory:
1 | local Object = LoadObject("/Game/Core/Blueprints/AI/BT_Enemy") |
For instance, loading a material ball for a model:
1 | function Cube3_Blueprint_C:ReceiveBeginPlay() |
Note: LuaObject in UnLua corresponds to LoadObject<Object>
:
1 | int32 UObject_Load(lua_State *L) |
Creating Objects
Note: Objects created in Lua with dynamic binding are bound to the UCLASS of that class, not the instance created from the New.
UnLua’s handling of NewObject
is covered in Global_NewObject
:
1 | FScopedLuaDynamicBinding Binding(L, Class, ANSI_TO_TCHAR(ModuleName), TableRef); |
SpawnActor
In Lua, SpawnActor and dynamic binding:
1 | local World = self:GetWorld() |
NewObject
In Lua, calling NewObject and dynamic binding:
1 | local ProxyObj = NewObject(ObjClass, self, nil, "Objects.ProxyObject") |
UnLua’s NewObject
can accept four parameters: the UClass being created, Outer, Name, and the Lua script for dynamic binding.
Component
Note: The original UnLua only allows loading BP’s UClass. Modifications are needed (modify
LuaLib_Class.cpp
‘sUClass_Load
function to eliminate the logic of adding the_C
suffix when passing a C++ class), and when creating Components, you also need to callOnComponentCreated
andRegisterComponent
. These two functions are not UFUNCTIONs and need to be manually exported.
Export OnComponentCreated
and RegisterComponent
from ActorComponent:
1 | // Export Actor Component |
Usage remains consistent with C++ usage:
1 | local StaticMeshClass = UE4.UClass.Load("/Script/Engine.StaticMeshComponent") |
UMG
Creating UMG first requires obtaining the UClass of the UI, then using UWidgetBlueprintLibrary::Create
to create it, consistent with C++:
1 | local UMG_C = UE4.UClass.Load("/Game/Test/BPUI_TestMain.BPUI_TestMain_C") |
Binding Delegates
Dynamic Multicast Delegates
In C++ code, a dynamic multicast delegate is written as follows:
1 | DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FGameInstanceDyDlg, const FString&, InString); |
In Lua, binding it can bind to Lua functions:
1 | local GameInstance = UE4.UFlibGameFrameworkStatics.GetNetGameInstance(self); |
Similarly, you can also invoke, clean up, and remove the delegate:
1 | -- remove |
Dynamic Delegates
In C++, the following dynamic delegate is declared:
1 | DECLARE_DYNAMIC_DELEGATE_OneParam(FGameInstanceDyDlg, const FString&, InString); |
In Lua, you bind it as follows:
1 | GameInstance.GameInstanceDyDlg:Bind(self, LoadingMap_C.BindGameInstanceDyMultiDlg) |
Dynamic delegates support Bind
/Unbind
/Execute
operations:
1 | -- bind |
Non-Dynamic Delegates Are Not Supported
Since BindStatic
/BindRaw
/BindUFunction
are all template functions, UnLua’s static export scheme does not support exporting them.
Official issue: How to correctly statically export normal delegates inherited from FScriptDelegate
Using Asynchronous Events
Delay
If you wish to use a function similar to Delay:
1 | /** |
In Lua, you can implement this using coroutine
:
1 | function LoadingMap_C:DelayFunc(Induration) |
You can bind it directly in coroutine.create
or bind to an existing function:
1 | function LoadingMap_C:DelayFunc(Induration) |
However, notice this point: when binding an existing Lua function, you need to pass an extra self
to denote the caller of the function.
1 | coroutine.resume(coroutine.create(LoadingMap_C.DoDelay), self, self, Induration) |
Here, the first self
indicates that LoadingMap_C.DoDelay
is being called via self
, while the last two parameters are passed to the coroutine function.
Invocation code looks like this:
1 | function LoadingMap_C:ReceiveBeginPlay() |
Note: For UFUNCTIONs with
FLatentActionInfo
, you can directly use the above method, but for UE encapsulated asynchronous nodes, which are not functions but class nodes, you need to export them yourself.
AsyncLoadPrimaryAsset
In C++, you can use UAsyncActionLoadPrimaryAsset::AsyncLoadPrimaryAsset
to load resources asynchronously:
1 | /** |
To use this in Lua, you need to export the FPrimaryAssetId
structure:
1 |
|
You can then use it in Lua to asynchronously load level resources and open them upon completion:
1 | function Cube_Blueprint_C:ReceiveBeginPlay() |
SetTimer
For some requirements, you need Timer to call continuously; in C++, you can use UKismetSystemLibrary::K2_SetTimerDelegate
, and in Blueprints, this corresponds to SetTimerByEvent
, which is a UFUNCTION, so you can also call it in Lua.
Binding the delegate and cleanup operations:
1 | function LoadingMap_C:ReceiveBeginPlay() |
{self, FUNCTION}
creates a Delegate. Initially, I thought I would need to export a method to create a Dynamic Delegate myself, but it turns out I didn’t need to.
Binding UMG Control Events
If you want to bind events like OnPressed
/OnClicked
/OnReleased
/OnHovered
/OnUnhovered
for UButton
, these are multicast delegates, so you’ll use Add
to add them:
1 | DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnButtonClickedEvent); |
In Lua, to bind:
1 | function UMG_LoadingMap_C:Construct() |
UE4.TArray
In UnLua, the index rule for using UE4.TArray
starts from 1, as opposed to the engine’s 0-based indexing. issues/41
Cast
The syntax for type casting in UnLua is:
1 | Obj:Cast(UE4.AActor) |
For instance:
1 | local Character = Pawn:Cast(UE4.ABP_CharacterBase_C) |
Using Blueprint Structures in Lua
Create a blueprint structure in bp:
In UnLua, you can access it in the following way:
1 | local bp_struct_ins = UE4.FBPStruct() |
You can use it like a C++ structure, but note that you need to add F
in front of the blueprint structure type name. In the example above, the name in the blueprint is BPStruct
, while in UnLua, it is accessed as FBPStruct
.
Insight Profiler
You can encapsulate SCOPED_NAMED_EVENT
on the Lua side, but since Lua does not have C++ RAII mechanisms, it cannot detect when it leaves the scope in real-time. Therefore, you can only add marks at the beginning and end:
1 | function TimerMgr:UpdateTimer(DeltaTime) |
The implementation on the C++ side:
1 |
|
Expose it to the Lua side:
1 |
|
Then you can use it in Lua and view the execution cost of the Lua code in Unreal Insight:
Three usage methods are provided:
1 | -- using 1 |
It is recommended to use the object creation method, as the created object will call End when the Lua object is destroyed if it is forgotten, preventing issues with Insight’s data collection. Using StaticBegin/StaticEnd
must ensure that all code branches can reach StaticEnd
.
You can combine Lua’s debug.getinfo(1).name
to get the function name and display it in Insight:
1 | function TimerMgr:UpdateTimer(DeltaTime) |