unlua实现原理

UnLua 实现原理解析

UnLua 是一个把 Lua 虚拟机与 UE 反射系统对接的脚本方案,让 Lua 能直接读写 UObject 属性、调用/覆写 C++ 与蓝图函数,从而实现热更新。

零、前言:为什么需要 UnLua?

  • ✅ 替代蓝图编写业务逻辑(无需重新编译)
  • ✅ 直接操作 UE 的 UObject 对象
  • ✅ 调用/覆写 C++ 函数和蓝图事件
  • ✅ 游戏打包后可以实现热更
  • ✅ 开发的时候可以热重载

一、实现概述

UnLua 模块会向 UE 注册 一个UObject创建的监听器,每当 UObject 被创建就触发回调。

若该类实现了 IUnLuaInterface(静态绑定)或动态指定了模块名,unlua就会 加载 对应 Lua 文件并完成绑定。

然后它用三层元表链 (实例→Lua模块→UClass 反射表)做方法/属性查找;属性和函数懒加载并缓存 ;对蓝图事件通过新建 ULuaFunction 并注入原函数字节码的方式实现覆写;C++ 调 Lua 靠 Registry 引用,Delegate 用 ULuaDelegateHandler 代理,Latent 函数用 Lua 协程的 yield/resume 实现异步。最终做到"Lua 写业务 + C++ 做底层 + 运行时热更新"。

  1. 自动绑定:监听 UObject 创建
    UnLua 模块会向 UE 注册 一个UObject创建的监听器,每当 UObject 被创建就触发回调。
    若该类实现了 IUnLuaInterface(静态绑定)或动态指定了模块名,就会 require 对应 Lua 文件并完成绑定。

2.绑定的本质:三层元表链

复制代码
INSTANCE(实例表,存 .Object 指向 UObject)
   └─metatable→ REQUIRED_MODULE(你写的 Lua 逻辑)
         └─metatable→ UCLASS Metatable(UE 反射信息)

查方法:先看你写的 Lua,找不到再走 UE 反射。

查属性:最终落到 __index / __newindex,通过反射读和写 C++ 成员。

  1. 函数覆写(最核心黑科技)
    针对 BlueprintImplementableEvent / BlueprintNativeEvent 这类"可覆写"函数。
    UnLua 新建一个 ULuaFunction,把原 UFunction 的 Script 字节码注入一条跳转指令,让 NativeFunc 指向 execCallLua。
    引擎调原函数 → 跳字节码 → execCallLua → 调到 Lua 函数。无需改 C++ 源码,运行时劫持。

UnLua 的架构总览

复制代码
┌─────────────────────────────────────────────────────────────┐
│                     Lua 脚本层                              │
│  local M = UnLua.Class()                                    │
│  function M:ReceiveBeginPlay() ... end                      │
│  return M                                                   │
├─────────────────────────────────────────────────────────────┤
│                  UnLua 绑定层                               │
│  ┌─────────────┐  ┌──────────────┐  ┌───────────────────┐  │
│  │ 静态绑定     │  │ 动态绑定      │  │ 函数覆写机制       │  │
│  │IUnLuaInterface│  │FLuaDynamicBnd│  │ULuaFunction+字节码│  │
│  └─────────────┘  └──────────────┘  └───────────────────┘  │
├─────────────────────────────────────────────────────────────┤
│                 UE 反射系统                                 │
│  UClass / UFunction / UProperty / FPropertyDesc            │
└─────────────────────────────────────────────────────────────┘

下面具体讲怎么实现的


二、核心概念通俗解释

想要实现热更,就需要用到虚拟机和lua,lua是嵌入式语言。

C++ 编译后会变成硬件能直接执行的机器码,一旦打包就改不了;

而CPU只会和虚拟机打交道,虚拟机本身是写死的,但是虚拟机读取的lua代码是可以随时变更的,所以这样就可以实现热更。

2.1 虚拟机(FLuaEnv)--- Lua 的"执行环境"

想象一下:Lua虚拟机就像一个独立的"小世界"

在这个"小世界"里:

  • 有自己的变量、函数、表(table)
  • 可以加载和执行 Lua 脚本文件
  • 与 UE 的 C++ 世界通过"桥梁"连接

2.2 绑定(Binding)--- 建立 C++ ↔ Lua 的连接

lua想要和C++交互,就需要建立绑定关系
绑定 就是让一个** /C++ 的 UObject 在 Lua 中有一个"替身"/ 。**

复制代码
C++ 世界                          Lua 世界
┌──────────────┐                ┌──────────────┐
│ ABattlePlayer│  ←── 绑定 ──→ │  INSTANCE表  │
│ (UObject)    │               │ .Object      │
└──────────────┘               └──────────────┘

当你访问 Lua 中的这个"替身"时,实际上是在操作对应的 C++ 对象。

2.3 元表(Metatable)--- Lua 的"面向对象魔法"

lua本身内部有自己的变量,想要实现lua可以操控自己的变量,又可以操控它所绑定的C++对象的变量,就要通过元表。

先介绍下如果通过元表实现继承:

Lua 本身不是面向对象语言,但它通过**元表(Metatable)**实现了类似 OOP 的行为:

  • 当你访问 obj.Name 时,如果 obj 里没有 Name
  • Lua 会自动去它的"元表"里找,如果书写__index函数
    这就是 UnLua 实现"继承"和"方法调用"的核心机制!

而另一方面,-** 元表还可以有元表,形成**查找链****

有了这种机制,就可以通过lua去调用它所绑定的C++物体的变量了,

通过__newIndex还可以实现写入


四、两种绑定方式详解

绑定,核心就是在于为C++类,声明好它要绑定哪个lua文件,首先需要为其实现IUnLuaInterface 接口,然后让其实现接口函数:GetModuleName,用来告诉UnLua这个类对应哪个Lua模块。

4.1 静态绑定 --- "预先约定"

适用场景:你明确知道哪些 C++ 类需要在 Lua 中使用。

实现方式 :让这些类继承 IUnLuaInterface 接口

在 NRC 项目中,很多 UI 类都使用了这种方式:

cpp 复制代码
// UIIcon.h - NRC项目实际代码
#include "UnLuaInterface.h"

UCLASS()
class NRC_API UUIIcon : public UNRCUserWidget, public IUnLuaInterface
{
    GENERATED_BODY()

public:
    // 必须实现的接口函数 --- 告诉UnLua这个类对应哪个Lua模块
    virtual FString GetModuleName_Implementation() const override;
    
    UFUNCTION(BlueprintImplementableEvent)
    void OnIconLoaded(const FString& assetPath, UObject* content);
};

工作流程

  1. C++ 类声明实现 IUnLuaInterface 接口
  2. 重写 GetModuleName_Implementation() 返回 Lua 模块名(如 "UIIcon"
  3. UnLua 自动监听对象创建 → 加载对应的 Lua 文件 → 建立绑定

项目案例:

类名 用途 所在文件
UUIIcon 图标控件 UI/Widgets/UIIcon.h
UNRCWidgetLoader Widget加载器 UI/Widgets/NRCWidgetLoader.h
UNRCGridViewEx 网格列表扩展 UI/Widgets/NRCGridViewEx.h
ABattlePlayer 战斗玩家角色 Battle/BattlePlayer.h

4.2 动态绑定 --- "按需创建"

适用场景:在 Lua 中动态创建对象并绑定。

lua 复制代码
-- Lua 中动态创建 Widget 并立即绑定
local IconWidget = NewObject(WidgetClass, self, nil, "Tutorials.IconWidget")
-- 此时 UnLua 会自动完成绑定流程

这种方式不需要 C++ 类事先实现接口,更加灵活。


上面为C++的对象声明了其要绑定的lua文件,接下来讲讲这个文件的创建过程。

五、对象绑定的内部机制

5.1 全局监听器监听UObject的创建,然后去绑定

UnLua 通过注册 UE 的全局对象监听器来实现自动绑定:

cpp 复制代码
class FUnLuaModule : public IUnLuaModule,
                     public FUObjectArray::FUObjectCreateListener,   // 创建监听
                     public FUObjectArray::FUObjectDeleteListener     // 销毁监听
{
    // 当任何UObject被创建时触发
    virtual void NotifyUObjectCreated(UObject *Object, int32 Index) override
    {
        // 检查是否需要绑定
        if (ShouldBind(Object))
        {
            Bind(Object);  // 执行绑定
        }
    }
};

这就像是给 UE 的"出生登记处"装了一个监控摄像头,每当新对象"出生",UnLua 就会检查是否需要给它安排一个 Lua 替身。

5.2 创建绑定文件

首先检查这个对象有没有实现是否实现了IUnLuaInterface,如果有实现,则加载其对应的lua文件,

然后导出类描述,也就是创建一个METATABLE_UOBJECT,它记录了记录了这个 UE 类(UClass)的所有反射信息------属性、函数、__index和__newIndex的方法。

然后为其创建一个实例表

这个实例表(Instance)里存的是一个userdata,这个userdata里装着一个指向C++对象的指针。

然后这个实例表接下来还会存你自己在lua里新建的变量。

接下来就会为其设定元表链结构了。

5.3 元表链结构(关键设计)

这是 UnLua 最精妙的设计之一!每个绑定的对象在 Lua 中形成一个三层元表链

lua 复制代码
第 1 层:INSTANCE (实例表)
           │
           │  .Object ── 指向 ABattlePlayer 的 C++ 指针
           │  (其他字段暂时没有)
           │
           │  设置元表 → 指向下面
           ▼
第 2 层:REQUIRED_MODULE (开发者写的 Lua 模块)
           │
           │  function M:OnHurt(Damage) ... end    ← 你在 Lua 里写的方法
           │  function M:ReceiveBeginPlay() ... end
           │
           │  设置元表 → 指向下面
           ▼
第 3 层:METATABLE_UOBJECT (UE 反射信息表)
           │
           │  __index    ── 通过 UE 反射读取 C++ 属性(比如 Health)
           │  __newindex ── 通过 UE 反射写入 C++ 属性
           │  (包含所有 UProperty/UFunction)
           │
           └─ 没有下一层了

这三层的职责分工:

层级 存放什么 举例
第1层 实例表 这个对象自己的临时数据 self.SomeTempFlag = true
第2层 Lua模块 你写的业务逻辑 function M:OnHurt()
第3层 UClass反射 UE 端定义的原生能力 C++ 的 HealthPlayAnim()

调用链示例

当你在 Lua 中调用 self:ReceiveBeginPlay() 时:

  1. 先在 INSTANCE 表中找 → 没有
  2. REQUIRED_MODULE (父类)找 → 找到了!(你定义的那个函数)→ 直接调用

当你写 self.Health 时:

  1. 先在 INSTANCE 表中找 → 没有
  2. REQUIRED_MODULE 找 → 没有
  3. 触发 METATABLE.__index → 通过 UE 反射找到 Health 属性 → 返回值

六、属性与方法的懒加载机制

6.1 为什么需要懒加载?

一个 UE 类可能有成百上千个属性和方法,如果在绑定时全部导出,会非常慢且浪费内存。

UnLua 的解决方案用到才导出,导出一次后缓存起来


七、函数覆写机制

7.1 什么是函数覆写?

覆写 就是用 Lua 函数替代 C++ 函数的实现。比如你想自定义 ReceiveBeginPlay() 的逻辑,就可以在 Lua 中写一个新的版本。

7.2 哪些函数可以被覆写?

其次,希望lua可以重写的C++的函数,需要将其声明为蓝图可覆盖:

C++实现 蓝图/Lua能力
UFUNCTION() ✅ 有实现 ❌ 不能覆写
UFUNCTION(BlueprintNativeEvent) ✅ 有默认实现 ✅ 可覆写(C++ 版本为 _Implementation()
UFUNCTION(BlueprintImplementableEvent) 无实现! ✅ 必须在蓝图/Lua 中实现

具体如下:

复制代码
    UFUNCTION(BlueprintImplementableEvent)
    void OnIconLoaded(const FString& assetPath, UObject* content);

7.3 覆写的实现原理(字节码注入)

这是 UnLua 整个方案中最精妙、也最"黑科技"的一部分。要讲清楚它的思路,得先回到一个最根本的难题。

7.3.1 核心难题:已编译的 C++ 函数根本改不了

当我们在 Lua 里写了一个 ReceiveBeginPlay 函数,希望它"替代"掉 C++ 原版的实现时,有一个绕不开的事实:C++ 函数在编译之后就变成了一段固定的机器码,被刻在 exe 里的某个地址上。既然是刻在那里的二进制指令,就不可能在游戏运行时把它挖出来、换成别的。

那是不是就没办法了?不是------虽然函数体本身改不了,但我们可以改变"引擎怎么找到它"这件事。UnLua 正是在"调用入口"这一层下手,让引擎看起来调用的是原函数,实际却跳到了 Lua 逻辑上去。

7.3.2 整体思路:偷梁换柱的两步走

UnLua 的覆写机制本质上可以概括成两句话:

  1. 造一个假的 UFunction (叫 ULuaFunction),它的 Native 入口直接连到一个叫 execCallLua 的 C 函数,这个函数的工作就是"转调到 Lua"。
  2. 改造原来的 UFunction ,让引擎在调用它时,第一时间就跳到我们造的这个假 UFunction 上去。

打个比方:原本引擎有一张"电话簿",上面记着"ReceiveBeginPlay 的号码是 C++ 版本"。UnLua 做的事情不是去删这条记录,而是在这个号码前面插了一个呼叫转移------你还是拨那个号,但电话响在 Lua 那边。这样整个 UE 引擎完全无感知,它以为自己还是在正常调用 UFunction。

整个替换关系如下:

复制代码
原始情况:
AGamePlayer::ReceiveBeginPlay() { /* 原始C++代码 */ }

覆写后:
┌─────────────────────┐
│ ULuaFunction (新建)  │ ← 假的UFunction,Native入口指向execCallLua
│ - NativeFunc: execCallLua │
│ - 引用: Lua函数地址   │
└─────────────────────┘
         ↑
         │ 劫持
         │
┌─────────────────────┐
│ 原始UFunction        │ ← 头部被注入一条"跳转指令"
│ - 注入: 跳到ULuaFunction │
└─────────────────────┘
7.3.3 第一步:建立"Lua 入口"------SetNativeFunc

UE 的 UFunction 里有一个关键字段叫 NativeFunc,它是一个 C 函数指针,决定了这个 UFunction 被调用时真正执行的那段 C++ 代码 。UnLua 创建 ULuaFunction 时,会把这个字段设置成自己写的 execCallLua

这样一来,只要引擎把调用路由到了这个 ULuaFunction,控制流就会自动进入 execCallLua------在那里 UnLua 会做三件事:从 Lua 虚拟机取出对应的 Lua 函数、把 UE 侧的参数压到 Lua 栈上、调用 Lua 函数并把返回值写回

然后 UnLua 还得把这个假 UFunction 注册进 UClass 的函数映射表里,这样 UE 反射系统才认得它。对应的核心代码是:

cpp 复制代码
// 把入口函数设置为 execCallLua ------ Lua 的中转站
SetNativeFunc(execCallLua);

// 注册到所属 UClass 的函数表里,让 UE 反射能查找到它
Class->AddFunctionToFunctionMap(this, *GetName());

做完这一步,一个"长得像 UFunction、实际调用会跳去 Lua"的新函数就造好了。但光有它还不够------因为引擎依然会去调原来那个 UFunction,根本不知道这个新东西存在。

7.3.4 第二步:劫持原函数------字节码注入

这里就要用到 UE UFunction 的另一个特性:每个 UFunction 都带一段字节码(Script 数组) ,引擎在执行蓝图/脚本函数时会先过这段字节码。UnLua 正是利用这一点,在原函数的字节码头部插入一条特殊指令------"立刻跳转到某个指定的 ULuaFunction 去执行"。

具体做法可以分解成三个动作:

  • 扩容 :在原 UFunction->Script 数组里预留一块空间,大小是 指令头 + 一个 ULuaFunction 指针
  • 定位:算出这块新空间的起始位置。
  • 写入:把指令头和我们那个假 UFunction 的地址写进去。

对应代码是:

cpp 复制代码
// 在原 UFunction 的字节码里扩出空间,用来装"跳转指令 + 目标地址"
Function->Script.AddUninitialized(
    ScriptMagicHeaderSize + sizeof(ULuaFunction*)
);

// 定位到新增空间的起始位置
uint8* Data = &Function->Script[Function->Script.Num() -
    (ScriptMagicHeaderSize + sizeof(ULuaFunction*))];

// 把我们的 ULuaFunction 指针写进去,作为跳转目标
FPlatformMemory::WriteUnaligned<ULuaFunction*>(Data + ScriptMagicHeaderSize, this);

这样一来,原 UFunction 的"开场白"就变成了一句"请改道去找 ULuaFunction"。从此刻起,任何一次对原函数的调用都会被这条指令截胡。

7.3.5 整体调用流程串起来

当上面两步做完后,整个覆写机制就形成了一条完整的重定向链。我们可以用一次"蓝图调用 PlayAnimByType"的过程来体会整个流程:

复制代码
① 引擎/蓝图发起调用 PlayAnimByType(...)
        ↓
② 进入原始的 UFunction,开始执行它的字节码
        ↓
③ 字节码第一条就是 UnLua 注入的"跳转指令"
        ↓
④ 控制流跳到我们造的 ULuaFunction 上
        ↓
⑤ ULuaFunction 的 NativeFunc = execCallLua
        ↓
⑥ execCallLua 查找对应的 Lua 函数,压参、调用
        ↓
⑦ Lua 中的 PlayAnimByType 执行业务逻辑
        ↓
⑧ 返回值写回 UE 栈,调用方拿到结果 ✓

全程 UE 引擎"以为"自己在走正常的 UFunction 调用链路 ,完全不知道中间被插了一刀。这种做法的高明之处在于:UnLua 既没有改 C++ 源码、没有重新编译引擎,也没有动原 UFunction 的任何逻辑结构,只是在字节码入口处悄悄地放了一条指引

7.3.6 为什么只有蓝图可覆写函数能走这条路?

这也是为什么第 7.2 节强调"只有 BlueprintImplementableEvent / BlueprintNativeEvent / 虚事件函数才能被覆写"------这些函数的调用路径本身就要经过字节码执行器 ,UnLua 的注入才有效。而普通的 UFUNCTION() 调用是直接 C++ 函数调用,根本不走字节码,也就没地方"注入跳转指令"了。

换句话说,UnLua 不是能覆写"所有 C++ 函数",而是能覆写"所有走字节码调用路径的 UFunction"。这是它的能力边界。

7.4 实际应用示例

cpp 复制代码
// GamePlayer.h
UCLASS()
class GAME_API AGamePlayer : public ACharacter
{
    GENERATED_BODY()
public:
    // BlueprintImplementableEvent = 可被Lua覆写!
    UFUNCTION(BlueprintImplementableEvent, BlueprintCallable)
    void PlayAnimByType(EPlayerAnimType Type);
};

对应的 Lua 代码可能是这样:

lua 复制代码
-- GamePlayer.lua (假设)
local M = UnLua.Class()

function M:PlayAnimByType(Type)
    -- 自定义动画播放逻辑,完全替代C++的实现
    if Type == EPlayerAnimType.Idle then
        self:PlayAnimation(IdleAnim)
    elseif Type == EPlayerAnimType.Attack then
        self:PlayAnimation(AttackAnim)
    end
end

return M

八、C++ 主动调用 Lua --- 回调机制

可以通过lua去覆写c++同名函数 然后c++调用这个函数即可

或者通过虚拟机的方式,具体可以看C++调用lua的方法总结。

简单来说就是,它可以直接调用全局函数,或者调用全局变量,将参数压入栈,然后就可以调用,函数返回值就在调用后的栈。

除此之外,还可以调用某个模块的指定函数,因为模块在lua里是唯一的。

还可以判断这个c++对象有没有绑定的lua对象,有的话也可以调用它的。


相关推荐
就叫飞六吧2 小时前
4399游戏平台开发技术栈拆解
游戏
开开心心就好2 小时前
整合多家平台资源的免费学习应用
人工智能·vscode·学习·游戏·音视频·语音识别·媒体
晴夏。2 小时前
c++调用lua的方法
c++·游戏引擎·lua·ue
晴夏。17 小时前
UE Spawn出来的Actor的生命周期和管理方法
游戏·ue5·ue4·ue
chxii20 小时前
lua中Table 与 Metatable
lua
RPGMZ20 小时前
RPGMakerMZ 地图存档点制作 标题继续游戏直接读取存档
开发语言·javascript·游戏·游戏引擎·rpgmz·rpgmakermz
柚要做甚码1 天前
godot-rust(gdext)2D游戏之旅【pong】 - 2
游戏·游戏开发
柚要做甚码1 天前
godot-rust(gdext)2D游戏之旅【pong】 - 3
游戏·游戏开发
盼小辉丶1 天前
PyTorch强化学习实战——构建生成对抗网络生成Atari游戏画面
pytorch·游戏·生成对抗网络