Unreal引擎接入Slua指南 - 辞别C++冗长编译
大师好 ,这里是留白,很久不见。今天来谈谈如何使用Slua,为Unreal接入Lua脚本语言。
我们为什么需要Lua
很多人认为本身UE开发独立/主机游戏不需要Lua。认为Lua是网游/手游热更才需要的,其实不尽然。
[*] Lua可以避免冗长C++编译。
[*]游戏玩法开发效率会大幅提升(不用反复开关Editor)。
[*]筹谋也可以改削Lua动态实现效果。
[*]Lua可以撑持玩家mod。
私以为,Unity在入门上比UE更好,其C#的IL2CPP,所改即所见是极为重要的。Lua则可以补足UE这一缺憾。
使用原生UE,面对游戏逻辑开发,会碰到如下问题:
[*]写C++虽然运行时高效,但面对持续变换的游戏逻辑,很容易引发crash。
[*]游戏不变性差,开发效率低。技术负债很容易累积。
[*]改到头文件(尤其是引擎等被深度依赖的模块),一次编译等一年。
[*]蓝图难以开发复杂逻辑。不竭的迭代,会导致蓝图规模痴肥。后期维护成本高。
[*]一旦发生两人蓝图开发冲突,合并Lua比合并蓝图效率高很多。
SLua是使UE撑持Lua的一个Plugin。接入Slua后,按照我的经验,你可以这样选择开发手段:
[*]C++:对性能敏感,一帧多次调用的需求。或者底层引擎功能。
[*]Lua:和游戏玩法逻辑关系大(UI),对性能不敏感,经常需求变换的
[*]蓝图:配置,尽量别放太多逻辑。
一般情况下,筹谋更容易接受蓝图。而且蓝图受官方撑持,资源更多。但接入Lua之后,建议少用蓝图。
对比:UnLua vs. SLua
市面上有两款适合Unreal的Lua开源方案。
一款是UnLua,另一款是Slua。都为腾讯旗下。
[*]Unlua文档更全面,新人易上手,开箱即用。
[*]Slua经历过腾讯潘多拉(商业化系统基石,王者荣耀等腾讯游戏氪金活动都靠他)和Pubgm两个重要项目的洗礼与打磨,是一把更加锋利不变的大刀。
Slua项目维护得十分积极。项目整体对比Unlua更加不变(Unlua经常会有莫名的crash,坑点多)。虽然文档上slua匮乏系统性,但这里依旧选择Slua。
接入安装
有官方教程,但我这里稍微展开详细一些,便于新人理解。
第一步,下载库:https://github.com/Tencent/sluaunreal。
[*]通过git下载
git clone https://github.com/Tencent/sluaunreal.git
[*]通过http下载 zip 包
https://github.com/Tencent/sluaunreal/archive/master.zipSlua库本身,带了一个UE的示例项目。
我们接入本身的项目只需要复制Plugins文件夹。
将`Plugins`内的lua_wrapper和slua_unreal两个模块,移动到游戏项目下即可。参考以下图片:
实际你的游戏工程下,可能不存在Plugins文件夹。创建即可。
Unreal构建系统(Unreal Build Tool)会自动扫描到Plugins目录下插件,编译游戏项目时会带上一起编译。 Slua长短侵入式的,不改削引擎。主要通过Hook代办代理(delegate)实现(详细逻辑参考slua的LuaOverrider.h)。
第二步 配置模块依赖。
这一块操作上,是改削游戏工程Source\<YourProject>\<YourProject.Build.cs>下文件。
using UnrealBuildTool;
public class SurviveProject : ModuleRules
{
public SurviveProject(ReadOnlyTargetRules Target) : base(Target)
{
// Precompiled headers
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
// Add slua_unreal dependency
PublicDependencyModuleNames.AddRange(new string[] { ”Core”, ”CoreUObject”, ”Engine”, ”InputCore” });
PrivateDependencyModuleNames.AddRange(new string[] { ”slua_unreal”, ”Slate”, ”SlateCore”, ”UMG” });
PrivateIncludePathModuleNames.AddRange(new string[] { ”slua_unreal” });
PublicIncludePathModuleNames.AddRange(new string[] { ”slua_unreal” });
}
}
这一步本质是让游戏工程C++模块,依赖于slua插件的C++模块。使我们游戏工程,可以调用slua功能。
这里简单展开一下C++模块的介绍,便于理解为什么需要这一步。
Unreal Build Tool有C++模块(Module)的概念,为编译的一个单元。
[*]插件、引擎、游戏工程都是C++模块。
[*]模块之间有依赖。
[*]依赖配置取决于是否使用其他模块功能(当#include其他模块的头文件时,需要配置依赖)。
[*]例如:游戏工程模块依赖于一些引擎模块(e.g.: Engine, Core)等。
[*]在每个模块下,有<ModuleName>.Build.cs的C#脚本,可以进行依赖关系等配置。
[*]若仅使用依赖模块的头文件声明declaration,不调用具体方式,则可以使用xxxIncludePathModuleNames,而不是xxxDependencyModuleNames。xxxDependencyModuleNames中的模块会进行链接。
[*]模块对外仅表露Public文件下的头文件(虽然你放.cpp也行)。
[*]模块在Win下会被编译成动态库.dll/静态库.lib,供链接。
对链接等编译概念模糊的话,可以测验考试本身编译一个.dll。可以参考这篇教程:大白老师:白话编程# 动态链接库开发(DLL开发,给非计算机专业的小伙伴看的教程)
第三步 重载GameInstance。
插件是成功编译了,但Lua的功能还未实际开启。
Lua运行代码,需要一个Lua State维护Lua 虚拟机状态。
GameInstance的生命周期斗劲长,对比World(切换Persistent Level地图时会加载/卸载),更适合维护Lua虚拟机。
官方有斗劲好的`GameInstance`例子,我们可以先接入,后续分析这个重载的内容。
[*](https://github.com/Tencent/sluaunreal/blob/master/Source/democpp/MyGameInstance.h)
[*](https://github.com/Tencent/sluaunreal/blob/master/Source/democpp/MyGameInstance.cpp)
法式:
[*]复制以上两个文件,在游戏工程模块进行编译。
[*]Editor中Project Setting 指定新定义的Game Instance为UMyGameInstance。
Lua虚拟机已就绪。在Content/Lua目录下写的Lua脚本就可以被识别到。具体如何写Lua脚本参考后文。
我们深入分析一下UMyGameInstance。
[*]什么时候启动/卸载Lua State?
[*]具体到`GameInstance`的哪些函数需要我们重载。
[*]Lua脚本从哪里加载?
[*]Editor/打包后读取方式是否一致?
[*]Lua脚本print输入/输出怎么控制?
[*]这个问题,也可以辅佐我们了解简单Lua与UE C++侧如何交互。
第一个问题:什么时候启动/卸载Lua State?
前文说过,`GameInstance`生命周期适合打点Lua虚拟机的Context,`Lua State`。
[*]在`GameInstance()`构造函数的时候启动Lua虚拟机。
[*]在`GameInstance::ShutDown()`的时候,卸载虚拟机。
UCLASS()
class SURVIVEPROJECT_API UMyGameInstance : public UGameInstance
{
GENERATED_BODY()
public:
UMyGameInstance();
...
virtual void Shutdown() override;
...
}
第二个问题:Lua脚本从哪里加载?
我们不雅察看以下`USurviveGameInstance::CreateLuaState()`中,创建虚拟机时的Delegate注册:
state->setLoadFileDelegate([](const char* fn, FString& filepath)->TArray<uint8> {
IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile();
FString path = FPaths::ProjectContentDir();
FString filename = UTF8_TO_TCHAR(fn);
path /= ”Lua”;
path /= filename.Replace(TEXT(”.”), TEXT(”/”));
TArray<uint8> Content;
TArray<FString> luaExts = { UTF8_TO_TCHAR(”.lua”), UTF8_TO_TCHAR(”.luac”) };
for (auto& it : luaExts) {
auto fullPath = path + *it;
FFileHelper::LoadFileToArray(Content, *fullPath);
if (Content.Num() > 0) {
filepath = fullPath;
return MoveTemp(Content);
}
}
return MoveTemp(Content);
});
可以看到,上文函数在lua `doFile`,`require`等操作的时候调用,从Content目录直接读取Lua脚本。
Editor模式下是OK。但,默认打包发布,是不会带上`Content/Lua`路径(只会cook .uasset进pak包),游戏会读不到Lua脚本。
[*]简单措置方式:在Project Settings -> Project -> Packaging -> Additional Non-Asset Directories to Copy带上Lua文件夹。这样,打包时会带上Lua目录进Content。打包背工动复制也行。
[*]简单方式缺陷:打包的游戏会留有Lua源码,玩家可以任意改削(对于撑持Mod是好事)。
[*]实际中,可以把Lua代码放在`Source\Lua`目录,Editor模式下从 `Source`目录读。Package时,将Lua文本Compile成`.luac`文件放在`Content\Lua`下。
安全问题:.luac其实也会有被篡改风险,实际中可能还会颠末lua opcode混淆等操作。这个斗劲高阶,在此不多赘述。
Editor时读Source/Lua,运行时读Content/Lua的方式:
FString path = FPaths::ProjectContentDir();
#if WITH_EDITOR
path = FPaths::GameSourceDir();
#endif
第三个问题:Lua脚本print输入/输出在哪里?
这点其实看上方流程图,会看到。在创建Lua虚拟机时,我们会定义一些全局Light C Function,用于Lua与UE交互。
[*]Light C Function对Lua内部是一个函数
[*]Light C Function外部与一个C函数绑定
此中,就包含了PrintLog函数,具体看以下代码:
// 创建Lua虚拟机时
void USurviveGameInstance::CreateLuaState()
{
// 注册一个Lua虚拟机创建完成时候的回调
NS_SLUA::LuaState::onInitEvent.AddUObject(this, &USurviveGameInstance::LuaStateInitCallback);
...
}
void USurviveGameInstance::LuaStateInitCallback(NS_SLUA::lua_State* L)
{
// Lua虚拟机创建成功后,我们往Lua _G全局表注册一个PrintLog函数
lua_pushcfunction(L, PrintLog);
lua_setglobal(L, ”PrintLog”);
}
static int32 PrintLog(NS_SLUA::lua_State* L)
{
// PrintLog函数拿下栈上第一个字符串
// 通过UE Log系统进行输出
FString str;
size_t len;
const char* s = luaL_tolstring(L, 1, &len);
if (s) str += UTF8_TO_TCHAR(s);
NS_SLUA::Log::Log(”PrintLog %s”, TCHAR_TO_UTF8(*str));
return 0;
}
Lua脚本编写示例:Actor BeginPlay时加载UI
此Chapter还未完工
在这个示例中,我们会用Lua编写一个Actor。
[*]放在场景BeginPlay时,会加载一个UMG UI(上图中背包列表)。
[*]EndPlay时会卸载。
Source\Lua\InGameManager.lua:
local actor = {}
function actor:ReceiveBeginPlay()
print(”InGameManager:ReceiveBeginPlay”)
self.bCanEverTick = true
self.Super:ReceiveBeginPlay()
self.ui = slua.loadUI('/Game/UMG/BackupUIBP.BackupUIBP', gworld)
self.ui:AddToViewport(0)
end
function actor:ReceiveEndPlay()
print(”InGameManager:ReceiveEndPlay”)
self.ui:RemoveFromViewport()
self.Super:ReceiveEndPlay()
end
function actor:Tick(reason)
self.Super:Tick(reason)
end
function actor:ReceiveTick(dt)
end
return Class(nil, nil, actor)2023/06/13编纂至此,施工中。。
楼主加油,干得不错! 哇哦
页:
[1]