性爱高手 发表于 2023-6-19 17:02

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(&#39;/Game/UMG/BackupUIBP.BackupUIBP&#39;, 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编纂至此,施工中。。

hqw333333 发表于 2023-6-19 17:02

楼主加油,干得不错!

alexgoodboy 发表于 2023-6-19 17:03

哇哦
页: [1]
查看完整版本: Unreal引擎接入Slua指南 - 辞别C++冗长编译