[{"content":"在 UE 项目里嵌一门脚本语言，动机通常不是\u0026quot;C++ 写得慢\u0026quot;，而是三件更具体的事：移动端要能热更（iOS 审核规则允许解释执行的脚本走补丁通道，重编二进制不行）、策划和玩法程序需要一个不用等编译的迭代循环、蓝图在逻辑规模上去之后维护成本失控（连线图不能 diff、不能 review、合并冲突基本无解）。\nLua 是这个位置上被验证过最多次的答案，而 UnLua 是腾讯开源的那一份。它的卖点是\u0026quot;零胶水代码\u0026quot;——不写任何 binding，Lua 里就能访问全部 UCLASS / UPROPERTY / UFUNCTION，还能直接覆写蓝图事件。\n这篇文章不讲怎么用（官方文档够了），而是把它拆开看：这套\u0026quot;零胶水\u0026quot;是靠什么换来的，代价落在哪里，哪些地方会咬人。\n分析基于 master 分支的 3f112e8，功能上等同于 v2.3.6（2023-11-07 发布，最后一个 tag）。文中所有 文件:行号 都指向这个版本。文末会单独说 2026 年的维护现状——它比\u0026quot;停更了\u0026quot;要复杂一点。\n一、三条路里选了最贵也最省事的那条 把 C++ 对象暴露给脚本，工程上只有三条路：\n方案 代表 代价 收益 手写绑定 传统 Lua 项目 每个类都要写胶水，接口一改就漏 调用开销最低，行为完全可控 代码生成 sluaunreal、puerts 需要跑生成器，产物进版本库，编译时间上涨 静态类型信息、接近手写的性能 运行时反射 UnLua 每次调用都要过反射层，类型错误只能运行时发现 零维护——引擎升级、蓝图改字段，脚本侧自动跟上 UnLua 选了第三条。UE 的反射系统（UClass / UFunction / FProperty）本来就是给蓝图 VM 和序列化用的完整运行时元数据，UnLua 相当于在蓝图 VM 旁边挂了第二个消费者。这个决定解释了它后面几乎所有的设计：好处是你新加一个 UPROPERTY 不需要动任何脚本层代码，坏处是每一次跨界访问都要付反射的钱，而且很多\u0026quot;坑\u0026quot;本质上是 UE 反射语义直接透出来的结果。\n整个插件（不含第三方 Lua）只有 148 个 h/cpp，其中运行时模块 UnLua 占 118 个，另外两个是编辑器模块和一个 UHT 插件：\nPlugins/UnLua/Source/ ├── UnLua/ # 运行时（118 文件）：LoadingPhase = PreDefault │ ├── Private/ReflectionUtils/ # FClassDesc / FFunctionDesc / FPropertyDesc │ ├── Private/Registries/ # 7 个 registry（Class/Object/Function/Property/Enum/Container/Delegate） │ ├── Private/BaseLib/ # 手写导出：TArray/TMap/TSet/Delegate/Object/Class... │ ├── Private/MathLib/ # 手写导出：FVector/FRotator/FTransform... │ └── Private/LuaFunction.cpp # 覆写机制的核心 ├── UnLuaEditor/ # 绑定按钮、模板生成、IntelliSense 生成 ├── UnLuaDefaultParamCollector/ # UHT 插件：把 C++ 默认参数值收集成表 └── ThirdParty/Lua/ # lua-5.4.3（默认）与 lua-5.4.4 二、一个 Actor 是怎么变成一张 Lua 表的 入口是一个空接口 UUnLuaInterface，只有一个方法 GetModuleName。蓝图里实现它、返回 \u0026quot;Player.BP_PlayerCharacter_C\u0026quot;，绑定就成立了。\n真正干活的是 FLuaEnv::TryBind（LuaEnv.cpp:336）。它挂在 GUObjectArray 的对象创建/删除监听上，每个新对象都过一遍：\nstatic UClass* InterfaceClass = UUnLuaInterface::StaticClass(); const bool bImplUnluaInterface = Class-\u0026gt;ImplementsInterface(InterfaceClass); ... if (Class-\u0026gt;GetName().Contains(TEXT(\u0026#34;SKEL_\u0026#34;))) // 跳过骨架类 return false; const auto ModuleName = ModuleLocator-\u0026gt;Locate(Object); 几个容易忽略的细节：\n模块名是从 CDO 上取的（ULuaModuleLocator::Locate，LuaModuleLocator.cpp:18），所以它是类级别的，同一个类的所有实例只能绑同一个 Lua 模块。要按实例区分，得走动态绑定（SpawnActor / NewObject 时传模块名）。 官方还提供了 ULuaModuleLocator_ByPackage：直接把包路径转成模块路径，连接口都不用实现。大型项目值得换成这个，省掉几百个蓝图逐个点绑定按钮。 异步加载线程里创建的对象不能立刻绑（Lua 不是线程安全的），会先进 Candidates 队列，等 OnAsyncLoadingFlushUpdate 回到主线程再处理。 绑定的第二步是 UUnLuaManager::BindClass，这里有一个很少被提到的行为——模块表会被浅拷贝一份：\nif (!Class-\u0026gt;IsChildOf\u0026lt;UBlueprintFunctionLibrary\u0026gt;()) { // 一个LuaModule可能会被绑定到一个UClass和它的子类，复制一个出来作为它们的实例的元表 lua_newtable(L); lua_pushnil(L); while (lua_next(L, -3) != 0) { ... } } UnLuaManager.cpp:283\n也就是说 require 回来的那张表不是实例真正用的那张。运行时往原模块表上塞东西，已绑定的实例看不到——这条同时也是热重载必须做额外工作的根因。\n第三步 FObjectRegistry::Bind（ObjectRegistry.cpp:113）把三层串起来：\nINSTANCE（每个对象一张空表） ├─ .Object = RAW_UOBJECT（userdata，里面存着 UObject*） └─ metatable → MODULE（模块表的拷贝，__index = Lua 的 Index 函数） ├─ .Super = 父模块表（UnLua.Class(super) 设的） ├─ .Overridden = CLASS_METATABLE └─ metatable → CLASS_METATABLE（每个 UStruct 一张，__index = Class_Index C 函数） 于是 self.Foo 的查找顺序天然就是：实例自己的字段 → Lua 模块链 → UE 反射。三层各管一段，语义很干净。\n最后 Bind 会调用 Lua 侧的 Initialize。注意这时对象还带着 RF_NeedInitialization，而 FFunctionDesc::CheckObject（FunctionDesc.cpp:550）会拦住任何 UFunction 调用：\nif (Object-\u0026gt;HasAnyFlags(RF_NeedInitialization)) { Error = FString::Printf(TEXT(\u0026#34;attempt to call UFunction \u0026#39;%s\u0026#39; in lua Initialize function on object \u0026#39;%s\u0026#39;.\u0026#34;), ...); Initialize 里只能初始化纯 Lua 状态，别碰引擎。\n三、核心：覆写是怎么做到的 这是 UnLua 最有意思的部分，也是官方文档已经过时的部分。\n3.1 先看 UFunction 有两种调用路径 UObject::ProcessEvent 最终会走到 UFunction::Invoke。如果函数带 FUNC_Native，就直接调它的 thunk 函数指针（FNativeFuncPtr）；否则进 ProcessInternal，由蓝图 VM 解释 Script 里的字节码。\nDocs/CN/How_To_Implement_Overriding.md 讲的是两套方案：替换 thunk 函数，以及注册一个新的 opcode 注入字节码。后者在 1.x 里确实存在。但在 2.3.6 的代码里 grep 不到任何自定义 opcode 注册——2.x 换了个更巧的做法。\n3.2 把指针藏在字节码里 LuaFunction.cpp 开头这两行是整个插件最精妙的地方：\nstatic constexpr uint8 ScriptMagicHeader[] = {EX_StringConst, \u0026#39;L\u0026#39;, \u0026#39;U\u0026#39;, \u0026#39;A\u0026#39;, \u0026#39;\\0\u0026#39;, EX_UInt64Const}; static constexpr size_t ScriptMagicHeaderSize = sizeof ScriptMagicHeader; LuaFunction.cpp:23\n覆写一个\u0026quot;类自己身上已有实现\u0026quot;的蓝图函数时（ULuaFunction::SetActive，LuaFunction.cpp:226）：\nScript = Function-\u0026gt;Script; // 原字节码搬到 ULuaFunction 上保存 Children = Function-\u0026gt;Children; // 参数属性链直接共享（不是拷贝） ... Function-\u0026gt;FunctionFlags |= FUNC_Native; Function-\u0026gt;SetNativeFunc(\u0026amp;execScriptCallLua); Function-\u0026gt;Script.Empty(); Function-\u0026gt;Script.AddUninitialized(ScriptMagicHeaderSize + sizeof(ULuaFunction*)); const auto Data = Function-\u0026gt;Script.GetData(); FPlatformMemory::Memcpy(Data, ScriptMagicHeader, ScriptMagicHeaderSize); FPlatformMemory::WriteUnaligned\u0026lt;ULuaFunction*\u0026gt;(Data + ScriptMagicHeaderSize, this); 原函数的字节码数组被清空，换成 6 字节魔数头 + 一个裸的 ULuaFunction* 指针。魔数头本身是用真实 opcode 拼的（EX_StringConst \u0026ldquo;LUA\\0\u0026rdquo; + EX_UInt64Const），所以这段 buffer 看上去像\u0026quot;压一个字符串常量再压一个 64 位常量\u0026quot;，格式上是自洽的。\n拿回来的时候（ULuaFunction::Get，LuaFunction.cpp:52）就是比对魔数 + 读指针：\nif (FPlatformMemory::Memcmp(Data, ScriptMagicHeader, ScriptMagicHeaderSize) != 0) return nullptr; return FPlatformMemory::ReadUnaligned\u0026lt;ULuaFunction*\u0026gt;(Data + ScriptMagicHeaderSize); 为什么要这么绕？因为关联关系需要长在 UFunction 对象自己身上。用外部 map 记录的话，蓝图重编译、FuncMap 重建、类被 GC，任何一次都会让映射失效；藏在 Script 里则跟着 UFunction 一起生死。\n3.3 一次覆写产生三个 UFunction 覆写完成后，内存里同名的东西有三份：\n对象 位置 内容 原 UFunction 还在原类的 FuncMap 里 FUNC_Native + thunk = execScriptCallLua，Script 里是魔数+指针 ULuaFunction ULuaOverridesClass（transient 包） 原字节码副本、共享的参数链、FFunctionDesc \u0026lt;Name\u0026gt;__Overridden 同上 原实现的完整复制品，self.Overridden 走到这里 ULuaOverridesClass（LuaOverridesClass.cpp:19）是个影子 UClass，建在 transient 包里，还特意打了 CLASS_NewerVersionExists 标记来躲开 FBlueprintActionDatabase::RefreshClassActions——不然编辑器的蓝图节点列表里会冒出一堆幽灵类。它把自己挂进目标类的 Children 链表，好让 GC 和 TFieldIterator 能看见里面的 ULuaFunction。\n为什么要搞影子类而不是原地改？ 因为覆写需要一个\u0026quot;能挂 UFunction 且不属于业务类\u0026quot;的容器：ULuaFunction 必须是某个 UClass 的 field 才能被正常引用和回收，同时又不能真的加进业务类的字段列表污染序列化。\n另一条分支是覆写继承来的函数（比如在 BP 类里覆写 AActor::ReceiveBeginPlay）。此时 Function-\u0026gt;GetOuter() != Class，走 bAddNew 路径：不动父类的 UFunction，只是在子类 FuncMap 里加一条新的 ULuaFunction，thunk 直接就是 execCallLua。这也是绝大多数实际用法。\n3.4 谁能被覆写 bool ULuaFunction::IsOverridable(const UFunction* Function) { static constexpr uint32 FlagMask = FUNC_Native | FUNC_Event | FUNC_Net; static constexpr uint32 FlagResult = FUNC_Native | FUNC_Event; return Function-\u0026gt;HasAnyFunctionFlags(FUNC_BlueprintEvent) || (Function-\u0026gt;FunctionFlags \u0026amp; FlagMask) == FlagResult; } LuaFunction.cpp:74\n翻译过来：只有蓝图事件（BlueprintImplementableEvent / BlueprintNativeEvent / 蓝图里自定义的 Event 和 Function）以及非网络的 native event 能被覆写。加上 GetOverridableFunctions 里额外扫的 ClassReps（RepNotify 函数），构成完整的可覆写集合。\n这解释了两个高频问题：\n为什么要写 ReceiveBeginPlay 而不是 BeginPlay：蓝图里看到的是 DisplayName，真实的 UFUNCTION 名字是 ReceiveBeginPlay。 为什么普通的 BlueprintCallable C++ 函数覆写不了：它没有 FUNC_Event。想改一段蓝图逻辑，官方给的办法是先在蓝图里 Collapse To Function，再覆写这个函数。 覆写清单本身是两个集合求交（UnLuaManager.cpp:309）：\n// 用LuaTable里所有的函数来替换Class上对应的UFunction for (const auto\u0026amp; LuaFuncName : BindInfo.LuaFunctions) { UFunction** Func = BindInfo.UEFunctions.Find(LuaFuncName); if (Func) ULuaFunction::Override(Function, Class, LuaFuncName); } Lua 表里的函数名 ∩ 可覆写 UFunction 名。名字对不上就只是个普通 Lua 方法，不会有任何报错——拼错函数名导致\u0026quot;覆写没生效\u0026quot;的调试成本，这里就是根源。\n输入事件、AnimNotify 走的是另一套：它们没有对应的 UFunction，UnLua 就拿 UUnLuaManager 上预先写好的模板函数（InputAction / InputAxis / TriggerAnimNotify…）复制一份、改成目标名字挂到业务类上，再把 FInputActionBinding 的委托绑到这个名字（UnLuaManager.cpp:367 起）。所以 Lua 里写 function M:Fire_Pressed() 就能收到输入，本质是\u0026quot;凭空造一个 UFunction 出来\u0026quot;。\n3.5 运行时怎么找到 Lua 函数 thunk 被调用后进 FFunctionRegistry::Invoke（FunctionRegistry.cpp:22）。第一次调用时它沿着 Super 链在元表里找同名 Lua 函数，然后把结果 luaL_ref 进注册表缓存起来：\ndo { lua_pushstring(L, FuncDesc-\u0026gt;GetLuaFunctionName()); lua_rawget(L, -2); if (lua_isfunction(L, -1)) { ...; FuncRef = luaL_ref(L, LUA_REGISTRYINDEX); break; } lua_pop(L, 1); lua_pushstring(L, \u0026#34;Super\u0026#34;); lua_rawget(L, -2); lua_remove(L, -2); } while (lua_istable(L, -1)); 找不到时不会崩，而是回落到原实现：\n// 可能因为Lua模块加载失败导致找不到对应的function，转发给原函数 const auto Overridden = Function-\u0026gt;GetOverridden(); if (Overridden \u0026amp;\u0026amp; Stack.Code) Overridden-\u0026gt;Invoke(Context, Stack, RESULT_PARAM); 这个设计很务实：脚本挂了，游戏退化成原来的蓝图行为，而不是当场炸。\n3.6 代价：FuncMap 是进程级的 覆写改的是 UClass::FuncMap，而 UClass 在整个进程里只有一份。这意味着：\n一个类的覆写对所有实例、所有 World、所有 PIE 会话同时生效； 编辑器里绑定过一次，状态会留到下一次 PIE，除非 Restore； 蓝图重编译会清空 FuncMap，覆写静默失效。 第三点的处理办法相当朴素——往 FuncMap 里塞一个哨兵函数，下次绑定时看它还在不在（UnLuaManager.cpp:262）：\n#if WITH_EDITOR // 兼容蓝图Recompile导致FuncMap被清空的情况 if (Class-\u0026gt;FindFunctionByName(\u0026#34;__UClassBindSucceeded\u0026#34;, EIncludeSuperFlag::Type::ExcludeSuper)) return true; ULuaFunction::RestoreOverrides(Class); #endif FLuaOverrides 还提供 Suspend/Resume，并且注册了 GUObjectArray 的删除监听，在类被销毁时自动 Restore。这些都是\u0026quot;改全局状态\u0026quot;必须付的税。\n四、访问路径：元表就是缓存 self.Health 看起来是一次表查找，实际走的步数值得算一下。\nLua 侧的 Index 函数（用 C 字符串内嵌在 UnLuaLib.cpp:163 的 chunk 里）：\nlocal function Index(t, k) local mt = getmetatable(t) local super = mt while super do -- 1. 先走 Lua 模块链 local v = rawget(super, k) if v ~= nil and not rawequal(v, NotExist) then rawset(t, k, v) -- 找到就缓存进实例表 return v end super = rawget(super, \u0026#34;Super\u0026#34;) end local p = mt[k] -- 2. 落到 CLASS_METATABLE 的 __index if p ~= nil then if type(p) == \u0026#34;userdata\u0026#34; then return GetUProperty(t, p) -- 3a. 属性：每次都要真读 elseif type(p) == \u0026#34;function\u0026#34; then rawset(t, k, p) -- 3b. 函数：闭包缓存进实例表 elseif rawequal(p, NotExist) then return nil end else rawset(mt, k, NotExist) -- 4. 连\u0026#34;不存在\u0026#34;也缓存 end return p end C 侧的 Class_Index → GetField（LuaCore.cpp:1061）逻辑是：\nlua_getmetatable(L, 1); lua_pushvalue(L, 2); int32 Type = lua_rawget(L, -2); if (Type == LUA_TNIL) GetFieldInternal(L); // 只有第一次会走反射解析 GetFieldInternal 解析出 FFieldDesc 后，把结果写回元表（LuaCore.cpp:1030）：属性存成一个装着 TSharedPtr\u0026lt;ITypeOps\u0026gt; 的 userdata，函数则直接压一个 Class_CallUFunction 闭包。继承来的字段会同时写进父类元表和子类元表两份缓存。\n所以真实成本是：\n访问 首次 之后 Lua 方法 self:Foo() 模块链 rawget 实例表命中，无 metamethod UFunction self:K2_GetActorLocation() 模块链 miss → C 调用 → 反射解析 → 建 FFunctionDesc 实例表命中闭包，直接进 CallUE 属性 self.Health 同上 每次：模块链 miss ×N → Class_Index（C 调用）→ 元表 rawget → 返回描述符 → GetUProperty（第二次 C 调用）→ 真读 不存在的字段 一次反射查找 NotExist 哨兵，纯表命中 属性读写是唯一没法被缓存掉的那一类——必须每次执行，而且要过两次 C 边界。更贵的是默认开着的类型检查（ENABLE_TYPE_CHECK=1），每次属性访问都会做一次 IsA（LowLevel.cpp:135）：\nUClass* OwnerClass = Property-\u0026gt;GetOwnerClass(); if (Object-\u0026gt;IsA(OwnerClass)) return true; luaL_error(L, ... \u0026#34;Access property from invalid owner. %s should be a %s.\u0026#34;); 实践结论很直接：热路径上别写 self.A.B.C，把属性和函数都拉到 local 里。顺便说一句，仓库自带的 benchmark（Content/Script/Tests/Benchmark/UnLuaBenchmarkProxy.lua）测的是 local RawObject = Proxy.Object 之后的裸 userdata 路径，跳过了 Lua Index 和模块链——它给的是下限，不是你在业务代码里会得到的数字。\n五、双向调用与参数 Lua → UE FFunctionDesc::CallUE（FunctionDesc.cpp:171）的骨架：\nBuffer-\u0026gt;Get() 拿参数帧； PreCall：逐个 InitializeValue + 从 Lua 栈 WriteValue_InContainer，缺的参数用 UHT 收集来的默认值补； Object-\u0026gt;UObject::ProcessEvent(FinalFunction, Params)——注意是非虚调用，绕过任何子类对 ProcessEvent 的重写； PostCall：返回值和出参压回 Lua 栈，然后 DestroyValue； Buffer-\u0026gt;Pop(Params)。 参数帧的分配策略由 ENABLE_PERSISTENT_PARAM_BUFFER（默认开）决定。持久模式下每个 UFunction 维护一个 buffer 栈，靠计数器支持递归（ParamBufferAllocator.cpp:38，这个计数器正是 2.3.5 修 #563 递归覆盖 bug 加的）；关掉则退化成每次 FMemory::Malloc + Memzero + Free。默认配置下，Lua 调 UE 函数在预热后不产生堆分配，代价是这些 buffer 只增不减。\n有个容易踩的语义：如果 Lua 调的这个 UFunction 自己就被 Lua 覆写过，会被重定向到原实现（FunctionDesc.cpp:213）：\n#if ENABLE_CALL_OVERRIDDEN_FUNCTION const auto LuaFunction = ULuaFunction::Get(Function.Get()); if (LuaFunction \u0026amp;\u0026amp; LuaFunction-\u0026gt;GetOverridden()) FinalFunction = LuaFunction-\u0026gt;GetOverridden(); #endif 这就是 self.Overridden.ReceiveBeginPlay(self) 能调到原蓝图逻辑的原因——Overridden 是 CLASS_METATABLE，从它拿到的是反射闭包，闭包进 CallUE 后被重定向。也因此只能写 . 不能写 :（Content/Script/Tutorials/02_OverrideBlueprintEvents.lua 里专门写了这条注意）：self.Overridden:SayHi(name) 会把元表当成 self 传进去。\nUE → Lua FFunctionDesc::CallLua（FunctionDesc.cpp:78）要处理一个麻烦：调用可能来自蓝图字节码，参数还躺在指令流里。于是有 bUnpackParams 分支，手动 Stack.Step 把每个参数解到 buffer 里，顺便重建一条 FOutParmRec 链；否则直接用 Stack.Locals。\n之后是 lua_pcall，然后按顺序把出参写回。这里有一段很诚实的注释（FunctionDesc.cpp:466）：\n// out value // suppose out param is also pushed on stack? this is assumed done by user... so we can not trust it Lua 函数不返回值时怎么办？ENABLE_TYPE_CHECK 开着就报错，关着就用默认值（2.3.5 改的行为）。返回值和出参的顺序由 UNLUA_LEGACY_RETURN_ORDER 控制。\n默认参数与协程 C++ 函数签名里的默认值在反射数据里是不存在的。UnLua 为此专门做了个 UHT 插件 UnLuaDefaultParamCollector，编译期扫所有 BlueprintCallable/Exec 函数，把默认值生成成 GDefaultParamCollection，运行时 PreCall 里补上。为了让 Lua 少写几个参数，代价是一个 build 期插件——这个取舍挺能说明 UnLua 的风格。\nLatent 函数（Delay、MoveTo 这类）走协程：PreCall 检测到名为 LatentInfo 的参数时，合成一个 FLatentActionInfo，回调目标写成 UUnLuaManager::OnLatentActionCompleted，LinkID 就是协程的 registry ref（FunctionDesc.cpp:300）：\nFLatentActionInfo LatentActionInfo(ThreadRef, GetTypeHash(FGuid::NewGuid()), TEXT(\u0026#34;OnLatentActionCompleted\u0026#34;), (Env.GetManager())); 引擎那边 latent action 完成后回调 → Env-\u0026gt;ResumeThread(LinkID) → lua_resume。所以 Lua 里可以直接写 UE.UKismetSystemLibrary.Delay(self, 1.0) 然后往下写，前提是这段代码跑在协程里。\n六、值语义：最容易炸的地方 这一节是我认为 UnLua 最需要提前知道的部分。\nFPropertyDesc::GetValueInternal 有个 bCreateCopy 参数。以结构体为例（PropertyDesc.cpp:1219）：\nif (bCreateCopy) { void *Userdata = NewUserdataWithPadding(L, StructSize, StructName.Get(), UserdataPadding); StructProperty-\u0026gt;InitializeValue(Userdata); StructProperty-\u0026gt;CopySingleValue(Userdata, ValuePtr); // 真拷贝 } else { UnLua::PushPointer(L, (void*)ValuePtr, StructName.Get(), bFirstPropOfScriptStruct); // 借用指针 } 而 Class_Index 读属性时传的是 false（LuaCore.cpp:1218）：\n(*Property)-\u0026gt;ReadValue_InContainer(L, Self, false); 也就是说 local t = self.SomeTransform 拿到的不是拷贝，是一个指向 UObject 内存的视图。改 t 就是改对象本身（很多人靠这个特性写 in-place 修改），但反过来：对象销毁、数组扩容搬家、参数帧复用之后，这个视图就是悬垂指针。\n更隐蔽的是覆写函数的参数。CallLuaInternal（FunctionDesc.cpp:449）：\nProperty-\u0026gt;ReadValue_InContainer(L, InParams, !UNLUA_LEGACY_ARGS_PASSING); UNLUA_LEGACY_ARGS_PASSING 默认是 1，取反就是 bCreateCopy = false——Lua 覆写函数收到的结构体参数，是指向调用方参数帧的指针。函数返回后那块 buffer 会被复用。把参数存进成员变量留到下一帧用，是标准的踩雷姿势。2.3.3 加的\u0026quot;默认传参\u0026quot;设置就是让你把它改成拷贝模式：安全，但每次调用多一次结构体拷贝。\n容器同理：TArray 的 Get 返回元素拷贝，GetRef 返回引用——示例代码里 InterpFloats:GetRef(1) 用的就是后者，因为它接着要改这个元素。\nUnLua 自己也知道这个设计有风险，配了两道防线：\n1. 悬垂检查（默认关）。FDanglingCheck（LuaDanglingCheck.cpp）在跨界调用外面套一个 guard，guard 析构时把这次调用中借出去的 struct userdata 指针置空、container userdata 打上 released 标记：\nvoid* Userdata = GetUserdataFast(L, -1, \u0026amp;TwoLevelPtr); check(TwoLevelPtr) *(void**)Userdata = nullptr; 于是你跨帧访问会得到一条 Lua 报错而不是随机崩溃。开发期建议开着。\n2. ReleasedPtr 哨兵。UObject 被销毁时，FObjectRegistry::Unbind（ObjectRegistry.cpp:200）不是简单地扔掉映射，而是把 userdata 里存的指针改写成一个特殊值：\n*((void**)Userdata) = (void*)LowLevel::ReleasedPtr; 之后 Lua 侧任何访问都会命中 IsReleasedPtr 检查，报 attempt to read property 'X' on released object。这比裸指针访问友好得多。\n七、两套 GC 的接缝 Lua 有自己的 GC，UE 有自己的 GC，两边都想管对象生死，接缝处就是 bug 的温床。UnLua 的处理是：Lua 侧全部用弱表，UE 侧用显式 root 集合。\nFLuaEnv 构造时建了一批弱值表（LuaEnv.cpp:121、ObjectRegistry.cpp:52）：UnLua_ObjectMap、StructMap、ArrayMap、ScriptContainerMap、UnLua_ManualRefProxyMap。它们只是\u0026quot;同一个 C++ 地址复用同一个 Lua 对象\u0026quot;的缓存，不阻止回收。\nUE 侧有两个 FObjectReferencer（LuaEnv.cpp:118）：\nAutoObjectReference：Lua 侧持有期间自动 root，Lua GC 掉对应 userdata 时通过 NotifyUObjectLuaGC 移除； ManualObjectReference：UnLua.Ref / UnLua.Unref 手动控制，配一个带 __gc 的 FManualRefProxy 兜底。 绑定过的对象另有一层：它的 INSTANCE 表被 luaL_ref 强引用在注册表里（ObjectRegistry.cpp:155），直到 Unbind 才释放。所以\u0026quot;Lua 表会不会比 UObject 活得久\u0026quot;这个问题的答案是：会，但只在 Unbind 之前，而 Unbind 由 NotifyUObjectDeleted 驱动。\nFLuaEnv::NotifyUObjectDeleted（LuaEnv.cpp:265）按固定顺序通知全部 registry——顺序本身就是踩过坑的产物：\nPropertyRegistry-\u0026gt;NotifyUObjectDeleted(Object); FunctionRegistry-\u0026gt;NotifyUObjectDeleted(Object); if (Manager) Manager-\u0026gt;NotifyUObjectDeleted(Object); ObjectRegistry-\u0026gt;NotifyUObjectDeleted(Object); ClassRegistry-\u0026gt;NotifyUObjectDeleted(Object); EnumRegistry-\u0026gt;NotifyUObjectDeleted(Object); Lua GC 参数也值得一提（LuaEnv.cpp:135）：\n#if 504 == LUA_VERSION_NUM lua_gc(L, LUA_GCGEN, 0, 0); // 5.4 默认开分代 GC #else lua_gc(L, LUA_GCSETPAUSE, 100); // 5.3 走激进的增量参数 lua_gc(L, LUA_GCSETSTEPMUL, 5000); #endif 而且 lua_State 用的是 UE 的分配器（FLuaEnv::DefaultLuaAllocator），所以 Lua 堆会出现在 UE 的内存统计里——这在排查移动端内存时很重要。\n顺带一句，从 CHANGELOG 看，\u0026ldquo;lua 在 GC 时偶现崩溃\u0026quot;这类问题从 2.2.0 的 GC 机制调整一直修到 2026 年 4 月（develop 上最新的提交就是\u0026quot;UObject在lua侧gc时偶现崩溃问题修复\u0026rdquo;）。两套 GC 的接缝确实是这个插件长期的痛点。\n实践规则：\n需要在 Lua 里长期持有的对象，别只靠一个 Lua 变量——要么它本来就有 UE 侧引用，要么 UnLua.Ref； 结构体和容器引用当成局部变量用完就扔，不要跨帧保存； 开发期打开悬垂检查，让问题在报错处暴露而不是在崩溃处； 访问可能已销毁的对象前先 IsValid。 八、性能：能从代码里推出来的部分 先说结论：Lua → UE 的反射调用，成本量级上和\u0026quot;蓝图节点调 C++ 函数\u0026quot;是同一档，因为两者最终都走 ProcessEvent。UnLua 真正的胜负手在于逻辑本身用 Lua 跑还是用蓝图 VM 跑——密集的分支、循环、表操作，Lua 明显更快；而跨界频次高的代码，两边都不便宜。\n各操作的固定开销来源：\n操作 每次都要付的成本 属性读 模块链 rawget ×N → C 调用 Class_Index → 元表 rawget → GetCppInstance → IsA（类型检查开时）→ 第二次 C 调用 GetUProperty → ReadValue 属性写 同上，末端换成 WriteValue UFunction 调用 闭包命中（已缓存）→ PreCall（逐参数 InitializeValue + 写入）→ GetFunctionCallspace → ProcessEvent → PostCall + DestroyValue 被覆写函数被调用 thunk → IUnLuaModule::GetEnv → registry 查 ULuaFunction → 可能的 Stack.Step 解参 → 逐参数压栈 → lua_pcall → 出参写回 静态导出函数调用 直接 C 函数，无 FFunctionDesc、无参数帧、无 ProcessEvent FString / FName 进出 每次都有编码转换和分配（TCHAR_TO_UTF8 / FString 构造） 数学类型运算 FVector 等是手写导出的，但每次运算结果是一块新 userdata 可调的开关（UnLua.Build.cs:91）：\n宏 默认 影响 ENABLE_TYPE_CHECK 1 关掉可省下每次属性访问的 IsA 和每个参数的类型校验；代价是类型错误变成未定义行为 ENABLE_PERSISTENT_PARAM_BUFFER 1 参数帧复用，消除每次调用的 malloc/free UNLUA_LEGACY_ARGS_PASSING 1 1 = 结构体参数借指针（快、危险），0 = 拷贝（慢、安全） ENABLE_CALL_OVERRIDDEN_FUNCTION 1 提供 self.Overridden，每次 CallUE 多一次 ULuaFunction::Get AUTO_UNLUA_STARTUP 1 引擎启动即建 lua_State UNLUA_ENABLE_DEBUG 0 大量日志，只在排查时开 ENABLE_UNREAL_INSIGHTS 0 lua_sethook 把 Lua 调用打进 Insights，与死循环检测互斥 代码层面能确定的优化手段就那么几条，但都实在：把 UFunction 和常用属性缓存到 local；用静态导出接管真正的热点；把 per-frame 的 Tick 逻辑留在 C++/蓝图里，只在事件驱动的地方进 Lua；数学运算尽量 in-place 而不是链式产生临时对象。\n内存侧要记住三笔账：每个绑定对象一张 Lua 表（还会随着方法调用把闭包 rawset 进去，越用越大）、每个 UStruct 一张元表（同时是字段解析缓存）、每个被 Lua 调过的 UFunction 一个只增不减的参数 buffer 池。\n九、热更新与工程化 热更新能力其实藏在模块加载器里。FLuaEnv 往 package.searchers 插了三个 searcher（LuaEnv.cpp:98），其中文件系统那个的查找顺序是（LuaEnv.cpp:622）：\n// 优先加载下载目录下的单文件 FPaths::Combine(FPaths::ProjectPersistentDownloadDir(), Pattern) // 其次是打包目录下的文件 FPaths::Combine(FPaths::ProjectDir(), Pattern) 下载目录优先于包内目录——这就是整条热更通道：把新脚本下到 PersistentDownloadDir，require 自然优先命中。默认搜索路径是 Content/Script/?.lua;Plugins/UnLua/Content/Script/?.lua，改 package.path 无效（UE 有自己的文件系统），要改 UnLua.PackagePath。想做加密或自定义打包格式，走 FLuaEnv::AddLoader 注册自定义 loader。\n开发期的 UnLua.HotReload() 是另一回事：HotReload.lua 明确写着参考云风的方案，尽量保留 upvalue 和运行时 table。但结合前面两个事实——模块表在绑定时被拷贝过、Lua 方法会被 rawset 缓存进实例表——就能推出它的能力边界：新逻辑对新实例总是生效，对老实例取决于缓存有没有被正确替换。文档自己的说法是\u0026quot;为开发期设计，尽量替换。最差的结果就是重新启动\u0026quot;，这个定位很诚实。\n工程化的其他部分：\nIntelliSense：UnLuaEditor 能遍历所有 UClass/UStruct/UEnum 生成 EmmyLua 注解存根，配 ---@type BP_PlayerCharacter_C 就有补全，还带一个 commandlet 方便进 CI。缺点是生成物需要在类型变化后重新生成。 打包：Lua 文件不是 asset，要在 Packaging 设置的 \u0026ldquo;Additional Non-Asset Directories to Package\u0026rdquo; 里加 Script 目录，这是官方 FAQ 第二条。 静态导出：UNLUA_EXPORT_CLASS / ADD_FUNCTION / ADD_PROPERTY / EXPORT_FUNCTION 一套宏，静态初始化期注册，和反射数据在同一张元表里合并。插件自己的 FVector、TArray 就是这么导出的——它自己的热点也不走反射，这点很说明问题。 测试：UnLuaTestSuite 里按 GitHub issue 号组织回归用例（Content/Script/Tests/Regression/Issue###/），几十个，甚至包括中文蓝图名（Issue322）这种 case。对一个开源插件来说算相当规范。 十、2026 年的现状：不是停更，是搬家了 这部分容易被误判，所以说清楚（数据取自 GitHub API，2026-08）：\n最后一个 tag 是 v2.3.6，2023-11-07，之后近三年没有发布过版本； master 分支自 2024 年起只有 1 个提交（2025-07-07 改 LICENSE 的公司主体）； 但 develop 分支还活着：UE5.4 支持在 2024-05（PR #700），UE5.6 编译修复在 2025-09 ~ 2025-12（PR #758），最新提交是 2026-04-02 的\u0026quot;UObject 在 lua 侧 gc 时偶现崩溃问题修复\u0026quot;（PR #765）； 推动这些提交的主要是社区贡献者（jozhn、crazytuzi 等）而非官方投入； UE5.7 目前不支持，相关 issue 还开着； 2755 stars / 718 forks / 185 个 open issues+PRs。 结论应该是：\u0026quot;官方发布线冻结在 UE5.3，真正可用的新版本在 develop 上，且靠社区维护\u0026quot;。如果你现在要接 UE5.4+，实际做法是从 develop 拉代码，并接受\u0026quot;没有 tag、没有 release note、issue 响应看志愿者心情\u0026quot;。\n同一时期的邻居们：\n项目 状态（2026-08） 绑定方式 UnLua 2755★，release 冻结在 2023-11，develop 活跃到 2026-04 运行时反射 sluaunreal（腾讯） 1974★，最后 push 2025-10，tag 2.1.4（2024-06） 反射 + 代码生成 puerts（腾讯，TS/JS） 6161★，最后 push 2026-07-31，三者中最活跃 代码生成 + 反射 UnrealEnginePython 最后 push 2022-06，事实上已废弃 反射 Angelscript（Hazelight） 不在 GitHub 开源，走 angelscript.hazelight.se 编译期静态绑定 关于 UnLua 的采用规模，官方 FAQ 里有一句可以直接引用的原话：\n腾讯内部已知的有四十款左右项目在使用UnLua，外部项目暂时无法统计。\n另外顺手说一个常被问的问题：能不能换 LuaJIT。仓库里确实有 feature/luajit 和 feature/lua51 分支（都停在 2023-02 的\u0026quot;增加：luajit的构建脚本\u0026quot;），2.3.5 也加了\u0026quot;自定义 Lua 版本\u0026quot;设置。但代码层面有硬约束——LuaCore.cpp 直接 #include \u0026quot;lstate.h\u0026quot; / \u0026quot;lobject.h\u0026quot;，自己重写了 lua_index2value，还在 userdata 尾部塞了一个魔数结构体来打标记：\n#define USERDATA_MAGIC 0x1688 #define BIT_TWOLEVEL_PTR (1 \u0026lt;\u0026lt; 5) struct FUserdataDesc { uint16 magic; uint8 tag; uint8 padding; }; 这套东西依赖的是 Lua 5.4 的 TValue/Udata 内存布局。换 LuaJIT 不是改个宏的事，得重做这一层。把它当成一个绑定在 Lua 5.4 上的插件来评估比较现实。\n什么时候该用它 适合：已经在用蓝图组织玩法、需要移动端热更、团队里有 Lua 积累、能接受\u0026quot;运行时才发现类型错误\u0026quot;的项目。它最大的价值是边际成本极低——加一个 UPROPERTY 不需要动任何绑定代码，这个好处在项目生命周期长、迭代频繁时会不断兑现。\n不适合：追求跨界调用极致性能（那应该静态绑定或干脆写 C++）、需要强类型和大规模重构支持（考虑 puerts 的 TS 路线或 Angelscript）、要跟最新引擎版本（develop 拉代码的运维成本要算进来）、团队没人愿意在出问题时读这套覆写机制的源码。\n最后一条其实是关键。UnLua 的代码质量不错，但它做的事情——在字节码里藏指针、给 UClass 挂影子类、改进程级 FuncMap、在两套 GC 之间借指针——注定不是黑盒。用它就要有人能读懂它。这篇文章希望能帮你把这个\u0026quot;有人\u0026quot;变得容易一点。\n","permalink":"https://hsiang0117.github.io/posts/unlua-source-analysis/","summary":"从源码层面拆解腾讯的 UE Lua 插件 UnLua：覆写机制如何把 Lua 函数塞进 UFunction、元表如何充当反射缓存、值语义为什么是最容易炸的地方、两套 GC 的接缝怎么缝，以及 2026 年它的真实维护状态。","title":"UnLua 源码剖析：Lua 是怎么长进 UE 反射系统里的"},{"content":"3DGS-Volume-Cloud GitHub\n一个用物理参数化 3D Gaussian Splatting 替代游戏引擎中 ray-marching 体积云的研究项目，目标是实时渲染 + 动态打光（推理时可任意替换太阳方向）。\n基于 3DGS (Kerbl et al., 2023) 代码框架，对表示、着色、光栅化器和训练管线做了参与介质（participating medium）方向的重构。\n为什么做这个 游戏引擎里的体积云长期依赖 ray-marching：每个像素沿视线步进几十次，再为每个采样点做一次光照 march，成本随分辨率与云层厚度上升。3D Gaussian Splatting 用光栅化替掉了逐像素步进，但代价落在表示上——原版每个高斯携带的是 SH 颜色加一个经验 opacity，这套参数描述的是\u0026quot;从某个方向看过去是什么颜色\u0026quot;，而不是\u0026quot;这团介质有多厚、散射多少、往哪个方向散\u0026quot;。直接拿它拟合云，等于把一种光照烘死：太阳一动，重建就不成立了。\n所以这里要解决的问题不是\u0026quot;能不能把云拟合得像\u0026quot;，而是能不能在保留光栅化速度的同时让表示本身是物理的——让每个高斯携带消光系数、散射反照率、相函数偏度这类参与介质量，使太阳方向变成推理时的输入，而不是训练时烘进去的常量。这个决定会一路传导下去：光学厚度得解析积分而非启发式 alpha 混合，自阴影得可微以便阴影梯度回传，致密化与剪枝里所有基于 opacity 的启发式也都要换成在物理参数化下仍然成立的形式。\n演示 您的浏览器不支持 video 标签。 两阶段设计 Stage 1 —— 仅太阳光、黑背景数据集，训出物理参数稳定的高斯点集。 Stage 2 —— 冻结 Stage 1 的几何与物理参数，只训一个全局环境光网络，叠加天空大气对云的着色，得到任意太阳方向的全光照 relighting。 物理化的高斯参数 每个高斯不再携带 SH 颜色 + 经验 opacity，而是一组参与介质物理量：\n参数 含义 激活 σ_t 峰值消光系数（1/m） softplus，clamp 5 ω 散射反照率（RGB） sigmoid g Henyey-Greenstein 相函数偏度 0.8·tanh，前向散射 w_n 6 阶多次散射八度能量权重 softplus 参数化是连贯的物理设计而非补丁：σ_t 是强度量（intensive），致密化 clone 时原样继承、不砍半；opacity 是 1−exp(−τ) 的解析量而非可学参数，因此原版的 reset-opacity 启发式被逐点\u0026quot;贡献度复活\u0026quot;（resurrect）取代。\n与原版 3DGS 的核心差异 📐 解析光学厚度光栅化 — fork 的 CUDA 光栅化器逐像素累积高斯沿视线的解析线积分光学厚度 τ，以物理正确的 Beer-Lambert 消光（α = 1 − exp(−τ)）替代启发式 alpha 混合。 ☀️ 光源视角自阴影（T_light） — 光照空间光栅化 pass 记录每个高斯朝向太阳的透射率（整个向阳 footprint 上的能量加权，而非中心点采样），配备原生可微 backward，把阴影梯度传播给所有前方遮挡者（含经 scale/rotation 的完整几何梯度）。 💡 物理着色与重打光 — 逐高斯辐射结合 HG 相函数、六阶多次散射八度与自阴影透射率；太阳方向是逐帧输入，训练好的云可从任意方向重新打光。 🪡 针手术（结构性 aniso 控制） — 实测高各向异性尾部 ~95% 是\u0026quot;薄盘\u0026quot;而非针，因此增肥薄轴 ×2（ratio 减半）而非劈长轴，沿主轴 ±σ_major/2 偏移生成双子，σ_t/3.2 守恒消光质量（体积 ×2 × 双子重叠 → 精确 /4 过砍，/3.2 实测质量中性）；等效硬上限，不与光度梯度拔河，每刀 ratio 减半保证 log2 收敛。 🌱 物理化致密化与维护 — 贡献度剪枝（per-Gaussian Σ(α·T) CUDA 通道）、σ_t 复活机制、自适应 densify 阈值；新分裂/克隆点带 500 迭代 prune_grace 宽限期，避免刚生成就被剪掉；维护回路仅在 densify 期运行，防止 settle 期的净销毁。 环境光（Stage 2） L = T_sun ⊙ [Stage 1 太阳项] + ω · Σ_lm E_lm · V_lm：\nT_sun —— 太阳大气透射率，仅 3 参数解析式 exp(−m(θ)·τ_RGB)（Kasten-Young air mass，固定几何）；低太阳变暗 + 变红（τ_B\u0026gt;τ_R）从结构自动落出，纯加性项表达不出\u0026quot;变暗\u0026quot;。 E_lm —— 天空辐亮度场的低阶 SH（小全局 MLP），加性内散射填充。 V_lm —— 逐高斯天空可见度 SH 传输向量，无色、纯几何，代码中以 buffer 而非可学参数存在——新增可学的只有全局 T_sun/E_lm，逐高斯不加任何色彩自由度，物理上无法退回 vanilla 3DGS。 环境网络与 V_lm 以 sidecar 存进 PLY 同目录，viewer/eval 自动加载。 数据集 UE5 渲染的体积云（WDAS cloud VDB）：60 个 Fibonacci 均匀半球太阳方向 × 轮转相机 = 1458 帧（train 1306 / test 152），NeRF-synthetic 格式并带逐帧 sun_direction，其中 4 个太阳方向整体 held-out（96 帧）作为重打光泛化测试。方向均匀覆盖是几何阴影梯度健康工作的前提。采集、坐标系转换、test 切分均脚本化（tools/）。\n交互 Viewer 基于 viser：实时拖动太阳方向做 relighting，可视化通道（RGB / T_light / σ_t / depth），可选 HDR 天空背景合成。\n技术栈 Python · CUDA · C++ · PyTorch — 自定义可微光栅化器（fork 自 diff-gaussian-rasterization，含 analytic-tau / record_front_tau / lightpass-backward 通道），三阶段采集管线（UE 采集 → 坐标系转换 → 数据集切分）。\n","permalink":"https://hsiang0117.github.io/projects/3dgs-volume-cloud/","summary":"用物理参数化的 3D Gaussian Splatting 实现实时体积云渲染与任意太阳方向重打光。","title":"3DGS-Volume-Cloud"},{"content":"WaterModifier GitHub\n一个 AI 辅助的桌面工具，用于编辑地理地形数据集中的水域掩码。浏览卫星瓦片地图，点几个前景 / 背景点，由 Segment Anything 分割出水域，结果直接写回 Cesium quantized-mesh .terrain 瓦片——并自动同步到各 LOD 等级。工具在真实 GIS 数据集（瑶湖机场，maxzoom 18）上完成验证。\n由两个协作进程构成：Unreal Engine 5.4 前端（地图浏览、可视化、交互）+ Python 后端（SAM 推理 + 地形文件二进制改写），通过 TCP socket（长度前缀协议）通信。\n为什么做这个 在 Cesium quantized-mesh 数据集里，水域掩码不是一张能单独打开、单独保存的图层，而是藏在每一个 .terrain 瓦片文件末尾的一段扩展记录里。它的偏移还不是固定的：要先跳过 88 字节文件头，再按各段自带的长度前缀依次跳过顶点数据、三角形索引（索引宽度按三角形数在 2 字节与 4 字节之间切换）、以及西 / 南 / 东 / 北四条边索引表，才能扫到扩展记录、判断里面有没有类型 2。同一片水域在 LOD 金字塔上还被复制了很多份——改动一级，下面每一级都有四倍数量的瓦片需要跟着改。\n于是\u0026quot;这片水库应该是水\u0026quot;这样一个人类尺度的编辑意图，落到数据上就是成千上万个瓦片文件的字节级重写。WaterModifier 要做的就是把这两端接起来：一端是按真实经纬度铺在屏幕上的瓦片金字塔和几次鼠标点击，另一端是批量的二进制改写与逐级同步。\n前端（UE 5.4，C++） 🗺️ TMS 瓦片地图浏览器 — 顶视正交相机，拖拽 / 滚轮缩放；瓦片管理器每帧 diff 视口覆盖的瓦片范围，仅增量加载新进入视野的瓦片 🌐 实时地理坐标 — 由瓦片集的 units-per-pixel 元数据实时换算经纬度；切换 LOD 时相机重新锚定到相同地理位置 💧 水域可视化 — C++ 直接解析 quantized-mesh .terrain 二进制格式（顶点 / 索引块、边索引、扩展记录——类型 2 即水域掩码），将现有水域以蓝色叠加显示 后端（Python + PyTorch） 🤖 SAM 分割 — 当前视口导出为 EXR、色调映射后喂给 Segment Anything（ViT-B，CUDA）；左键为前景提示点、右键为背景点，可反复加点迭代直到掩码满意 ✍️ 手动模式 — Photoshop 钢笔式多边形选区，射线法点内测试栅格化 📝 地形文件原地改写 — 逐瓦片掩码合并，支持覆盖与叠加两种模式，兼容 1 字节统一掩码和完整 256×256 掩码，并能为从未有过水域掩码的瓦片追加扩展 🧹 形态学清理 — 开 / 闭运算去除分割噪点，再经平滑卷积让水域边缘自然过渡 🔁 LOD 同步 — 修改递归下推至更高缩放等级：父瓦片掩码四象限拆分 + 最近邻上采样，一路同步到数据集最高 LOD 📊 实时进度 — 批量写入期间，定时线程每 0.5 秒向 UI 回传已修改瓦片数 关键技术点 双端二进制格式一致性 — Cesium quantized-mesh-1.0 的解析在两端各实现一遍：Python 端负责写（手动走 header / 顶点 / 索引块 / 边块 / 扩展记录，索引宽度按三角形数自适应 2 / 4 字节），UE C++ 端负责读（可视化），两端对格式的理解必须逐字节一致 相机 ↔ 瓦片坐标换算 — C++ 函数库实现相机视图 → 瓦片编号 → 地理坐标的完整链路（含 tilemapresource.xml 解析、units-per-pixel 换算） 设计取舍 🔌 socket 只走指令，像素走文件系统 — 协议里只有指令关键字加字符串参数（选点坐标、LOD、地形根目录、左下角瓦片编号、视野范围、瓦片尺寸），回复是 SegmentDone / ModifyDone 这类短字符串。两张图片改在工作目录里交接：UE 把当前视口的 Render Target 导出为 EXR，Python 读它做分割；Python 写出掩码 PNG，UE 再加载成运行时贴图。被放弃的方案是把像素缓冲直接塞进 socket；代价是两个进程被绑在同一个工作目录上。 🧭 逐段跳过，而不是直接定位掩码 — 掩码偏移取决于顶点数、三角形数与索引宽度，固定偏移这条路走不通，只能按每段自带的长度前缀一段段跳。代价是同一套遍历逻辑在两端各实现一遍——Python 端负责写、UE C++ 端负责读——两边对字段偏移与跳转规则的理解必须始终一致。 📦 写入端始终输出完整网格 — 掩码负载要么是 1 字节的全水 / 全陆统一标志，要么是完整的 256×256 = 65536 字节网格。遇到 1 字节负载时直接升格：改写长度域、删掉那一个字节、追加完整网格；完全没有掩码扩展的瓦片则在文件末尾追加一条扩展记录。保留紧凑的 1 字节形式本可以省空间，但下游每一处都要维护两条分支；代价是整文件的读—改—重写，而不是原地字节修补。 📄 三个文件名就是数据集契约 — 瓦片地图根目录要有 tilemapresource.xml（提供各 LOD 的 units-per-pixel 列表）与 meta.json（提供 maxzoom），地形根目录要有 layer.json。maxzoom 是读文件得来的，而不是从磁盘上的金字塔结构推断的：好处是不必扫目录、行为可预期，代价是数据集必须按约定提供这个文件。 ⏱️ 进度用自我重挂的定时器回传 — 批量修改期间由一个 0.5 秒定时器把已完成瓦片数写回 UI，每写回一个 .terrain 文件计数加一（含递归同步到的子瓦片）。好处是实现简单、不侵入写入循环；代价是这条数据绕开了带长度前缀的协议，接收端要单独识别。 技术栈 UE 5.4 / C++ · Python / PyTorch — segment-anything（ViT-B）、NumPy / SciPy / OpenCV、TcpSocketPlugin；作用于 TMS 瓦片金字塔与 Cesium quantized-mesh-1.0 地形数据。 分割使用 SAM ViT-B（segment_anything 锁定到上游特定 commit，torch 2.4.1 + cu124）；界面提供 256 / 512 / 768 / 1024 / 2048 五档视野范围，默认 1024。\n","permalink":"https://hsiang0117.github.io/projects/water-modifier/","summary":"AI 辅助的地形水域掩码编辑工具——在地图上点几个点，Segment Anything 自动分割水域，修改结果同步到所有 LOD 等级。","title":"WaterModifier"},{"content":"TinyOpenGLRenderer GitHub\n一个基于 OpenGL 4.5 + C++17 的迷你实时渲染器 / 场景编辑器，用于学习与实验现代渲染技术。组件驱动的 ECS-lite 架构、RenderPass 流水线、Dear ImGui（docking）编辑器界面。\n为什么做这个 现代实时渲染的单项技术在网上都找得到教程，但这些教程往往彼此独立：一个 demo 讲阴影、另一个讲体积云、第三个讲骨骼动画，参数写死在代码里，改一个值就要重编译。这个项目要的是相反的东西——把这些技术收进同一个场景，让它们共享同一套光照与同一条渲染流水线，并且所有参数都能在运行时改、改完立刻看到结果。\n因此有相当一部分工程量花在了不直接产生像素的地方：把渲染拆成显式的 RenderPass 序列（各 pass 只通过 RenderContext 交换状态），把可渲染物体做成挂在 GameObject 上的组件，再为每类组件配一个 Inspector 面板。收益是体积云那十几个参数可以边拖边看，同一场景里还能同时存在参数不同的多朵云；代价是相比一个专门写死的 demo，多了一层间接。\n截图 渲染 🎨 前向 HDR 管线 — RGBA16F 渲染目标、Bloom（高斯 ping-pong 模糊）、曝光色调映射 💡 多光源 — 平行光 / 点光 / 聚光，光源数据经 SSBO 上传 🌑 阴影 — 平行光 2D shadow map（front-face culling 防 peter-panning）；点光 cube map array 全向阴影 ✂️ 真·视锥剔除 — Gribb-Hartmann 六平面提取 + AABB 测试，场景树中不可见的物体（含点光源）不提交绘制 ☁️ 体积云（Horizon 风格 raymarch）— Perlin-Worley 噪声（低频 FBM 成形 + 高频 Worley 边缘侵蚀、域扭曲、weather map 控制覆盖率）、双叶 HG 相函数、4 阶多重散射近似、powder 暗边、自阴影 light march；半分辨率渲染 + 深度感知双边上采样、空步跳过、世界尺度自适应步数。噪声纹理由工作线程后台并行生成，并带版本化自描述磁盘缓存（参数变化自动失效重建）。云类型 / 覆盖率 / 噪声尺度 / 光照 / 风速等 15 项参数全部组件化，Inspector 实时可调，同场景可多朵不同的云 🦴 骨骼动画 — Assimp 导入、GPU 蒙皮、骨骼调试叠加显示 🌅 天空盒与异步模型加载（PLY 及常见格式） 编辑器 🧩 Dear ImGui docking — 场景树 / Inspector / 渲染设置 / 资源 / 渲染到纹理的 Viewport 🔧 组件式 Inspector — Transform、光源、材质、体积云等组件均有对应编辑面板 📌 选中 Gizmo — 选中物体原点处渲染 XYZ 轴向箭头（随物体旋转、始终最前、恒定屏幕尺寸） 📜 应用内日志控制台 — GL 调试输出 / 引擎日志直接显示在编辑器内，无系统命令行窗口 ➕ 数据驱动的 Add 菜单 — 一键创建光源 / 网格 / 天空盒 / 体积云 架构 ECS-lite — GameObject + 组件，场景视图按类型缓存 RenderPass 流水线 — FrameSetup → Shadows → LightUpload → ForwardHdr → Volume/Skeleton → Gizmo → Bloom → Composite，各 pass 仅通过 RenderContext 共享状态 依赖注入 — Engine 为组合根，无单例（Logger 除外） GL 资源 RAII — 纹理 / FBO / Buffer 均为 move-only 包装 驱动细节处理 — 静态网格统一绑定骨骼纹理位（单元 8）以保证采样器完整性通过驱动校验，无动画场景也不会踩 GL 验证错误 技术栈 C++17 · OpenGL 4.5 — GLFW、glad、glm、Assimp、Dear ImGui（docking）、stb_image。Visual Studio 2022（x64）构建，依赖全部捆绑在仓库内。 依赖已捆绑在仓库内（include/ + lib/），打开 .sln 选 Debug/Release x64 直接生成；启动后自动加载一个带天空盒、地面与平行光的默认场景。\n","permalink":"https://hsiang0117.github.io/projects/tiny-opengl-renderer/","summary":"基于 OpenGL 4.5 + C++17 的迷你实时渲染器 / 场景编辑器——前向 HDR 管线、阴影、骨骼动画、raymarch 体积云，以及 Dear ImGui docking 编辑器。","title":"TinyOpenGLRenderer"},{"content":"相信很多学习opengl的朋友都是从learnopengl这个教程开始的。网络上有很多配置opengl环境的教程，但大多数使用的ide都是vs，Clion相比vs有一个巨大的优势可以通过Cmakelists配置运行多个main函数，意味着在学习后面内容的章节时不用将以前写的代码清除掉，可以直接新开一个cpp文件写一个新的main函数。\n由于learnopengl教程中使用的模型导入库assimp没有mingw的预编译版本，自己使用mingw编译也一直报错，所以会将Clion切换到vs工具链以便正常使用assimp库。\n一、切换Clion到vs工具链 开始之前，确保已经安装好了vs的c++开发环境。\nClion中创建好C++项目后，来到设置-\u0026gt;构建、执行、部署-\u0026gt;工具链，点击添加一个工具链，工具集选择你的vs安装目录，下面的不用管会自动识别，点击应用。\n之后来到设置-\u0026gt;构建、执行、部署-\u0026gt;Cmake，点击添加一个配置文件，取一个名字，就叫Debug-vs2022，可以把默认的mingw也重命名为Debug-Mingw方便区分。构建类型选择Debug，工具链选择刚刚添加的vs工具链，点击应用，确定。\n之后右上角的cmake配置文件就会出现两个选项，左侧项目目录内也会出现两个配置分别对应的目录。使用vs的配置文件运行一下实例代码看看是否能正常运行。\n之后，在项目根目录下创建include、lib、src三个目录。\n二、配置glfw 到glfw官网 An OpenGL library | GLFW 下载压缩包并解压，获得以下目录。\n将include/GLFW整个文件夹放入include目录下，lib-vc2022/glfw3.lib放入lib目录下。\n三、配置glad 到glad官网 glad.dav1d.de 选择自己需要的版本下载并解压，得到如下目录。\n将include/glad和include/KHR两个文件夹都复制到项目include目录下，src/glad.c复制到项目src目录下。\n四、配置assimp 可以去GitHub仓库中拉取代码自己编译，或者下载预编译版本 https://kimkulling.itch.io/the-asset-importer-lib 官方提供的是一个exe安装包，下载后安装到任意目录。\n来到安装目录，将include/assimp整个文件夹复制到项目下include目录中，lib/x64/assimp-vc143-mt.lib复制到项目下lib目录中，bin/x64/assimp-vc143-mt.dll复制到cmake-build-debug-vs2022目录中（你添加的vs配置文件对应的build目录中），之后就可以把安装的assimp卸载了。\n五、其他配置 glm库：github克隆仓库后将glm文件夹复制到项目include目录下。\nstb_image库：将stb_image.h放入include目录下。\n此处不过多赘述。\n六、配置cmakelists 在项目根目录下创建demos和headers两个目录，demos用于存放要运行的cpp源文件，headers用于存放自己写的头文件如camera.h，shader.h等。也可以不要headers目录直接把自己写的头文件也放在include下，但做好区分好一些。\n之后在Cmakelists.txt写入以下内容：\ncmake_minimum_required(VERSION 3.30) project(LearnOpengl) #括号内改为你自己的项目名 set(CMAKE_CXX_STANDARD 20) include_directories(${PROJECT_SOURCE_DIR}/include ${PROJECT_SOURCE_DIR}/headers) link_directories(${PROJECT_SOURCE_DIR}/lib) file(GLOB files demos/*.cpp) foreach (file ${files}) string(REGEX REPLACE \u0026#34;.+/(.+)\\\\..*\u0026#34; \u0026#34;\\\\1\u0026#34; file_name ${file}) add_executable(${file_name} src/glad.c ${file}) target_link_libraries(${file_name} ${PROJECT_SOURCE_DIR}/lib/glfw3.lib) target_link_libraries(${file_name} ${PROJECT_SOURCE_DIR}/lib/assimp-vc143-mtd.lib) endforeach () 右键Cmakelists.txt选择重新加载Cmake项目，会自动为demos目录下每一个cpp文件生成一个可编译运行的配置，可以运行以下代码看看glfw和glad是否配置成功。\n#include \u0026lt;glad/glad.h\u0026gt; #include \u0026lt;GLFW/glfw3.h\u0026gt; #include \u0026lt;iostream\u0026gt; void framebuffer_size_callback(GLFWwindow* window, int width, int height); void processInput(GLFWwindow *window); // settings const unsigned int SCR_WIDTH = 800; const unsigned int SCR_HEIGHT = 600; const char *vertexShaderSource = \u0026#34;#version 330 core\\n\u0026#34; \u0026#34;layout (location = 0) in vec3 aPos;\\n\u0026#34; \u0026#34;void main()\\n\u0026#34; \u0026#34;{\\n\u0026#34; \u0026#34; gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\\n\u0026#34; \u0026#34;}\\0\u0026#34;; const char *fragmentShaderSource = \u0026#34;#version 330 core\\n\u0026#34; \u0026#34;out vec4 FragColor;\\n\u0026#34; \u0026#34;void main()\\n\u0026#34; \u0026#34;{\\n\u0026#34; \u0026#34; FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);\\n\u0026#34; \u0026#34;}\\n\\0\u0026#34;; int main() { // glfw: initialize and configure // ------------------------------ glfwInit(); glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); #ifdef __APPLE__ glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); #endif // glfw window creation // -------------------- GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, \u0026#34;LearnOpenGL\u0026#34;, NULL, NULL); if (window == NULL) { std::cout \u0026lt;\u0026lt; \u0026#34;Failed to create GLFW window\u0026#34; \u0026lt;\u0026lt; std::endl; glfwTerminate(); return -1; } glfwMakeContextCurrent(window); glfwSetFramebufferSizeCallback(window, framebuffer_size_callback); // glad: load all OpenGL function pointers // --------------------------------------- if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) { std::cout \u0026lt;\u0026lt; \u0026#34;Failed to initialize GLAD\u0026#34; \u0026lt;\u0026lt; std::endl; return -1; } // build and compile our shader program // ------------------------------------ // vertex shader unsigned int vertexShader = glCreateShader(GL_VERTEX_SHADER); glShaderSource(vertexShader, 1, \u0026amp;vertexShaderSource, NULL); glCompileShader(vertexShader); // check for shader compile errors int success; char infoLog[512]; glGetShaderiv(vertexShader, GL_COMPILE_STATUS, \u0026amp;success); if (!success) { glGetShaderInfoLog(vertexShader, 512, NULL, infoLog); std::cout \u0026lt;\u0026lt; \u0026#34;ERROR::SHADER::VERTEX::COMPILATION_FAILED\\n\u0026#34; \u0026lt;\u0026lt; infoLog \u0026lt;\u0026lt; std::endl; } // fragment shader unsigned int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); glShaderSource(fragmentShader, 1, \u0026amp;fragmentShaderSource, NULL); glCompileShader(fragmentShader); // check for shader compile errors glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, \u0026amp;success); if (!success) { glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog); std::cout \u0026lt;\u0026lt; \u0026#34;ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\\n\u0026#34; \u0026lt;\u0026lt; infoLog \u0026lt;\u0026lt; std::endl; } // link shaders unsigned int shaderProgram = glCreateProgram(); glAttachShader(shaderProgram, vertexShader); glAttachShader(shaderProgram, fragmentShader); glLinkProgram(shaderProgram); // check for linking errors glGetProgramiv(shaderProgram, GL_LINK_STATUS, \u0026amp;success); if (!success) { glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog); std::cout \u0026lt;\u0026lt; \u0026#34;ERROR::SHADER::PROGRAM::LINKING_FAILED\\n\u0026#34; \u0026lt;\u0026lt; infoLog \u0026lt;\u0026lt; std::endl; } glDeleteShader(vertexShader); glDeleteShader(fragmentShader); // set up vertex data (and buffer(s)) and configure vertex attributes // ------------------------------------------------------------------ float vertices[] = { -0.5f, -0.5f, 0.0f, // left 0.5f, -0.5f, 0.0f, // right 0.0f, 0.5f, 0.0f // top }; unsigned int VBO, VAO; glGenVertexArrays(1, \u0026amp;VAO); glGenBuffers(1, \u0026amp;VBO); // bind the Vertex Array Object first, then bind and set vertex buffer(s), and then configure vertex attributes(s). glBindVertexArray(VAO); glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); glEnableVertexAttribArray(0); // note that this is allowed, the call to glVertexAttribPointer registered VBO as the vertex attribute\u0026#39;s bound vertex buffer object so afterwards we can safely unbind glBindBuffer(GL_ARRAY_BUFFER, 0); // You can unbind the VAO afterwards so other VAO calls won\u0026#39;t accidentally modify this VAO, but this rarely happens. Modifying other // VAOs requires a call to glBindVertexArray anyways so we generally don\u0026#39;t unbind VAOs (nor VBOs) when it\u0026#39;s not directly necessary. glBindVertexArray(0); // uncomment this call to draw in wireframe polygons. //glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); // render loop // ----------- while (!glfwWindowShouldClose(window)) { // input // ----- processInput(window); // render // ------ glClearColor(0.2f, 0.3f, 0.3f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); // draw our first triangle glUseProgram(shaderProgram); glBindVertexArray(VAO); // seeing as we only have a single VAO there\u0026#39;s no need to bind it every time, but we\u0026#39;ll do so to keep things a bit more organized glDrawArrays(GL_TRIANGLES, 0, 3); // glBindVertexArray(0); // no need to unbind it every time // glfw: swap buffers and poll IO events (keys pressed/released, mouse moved etc.) // ------------------------------------------------------------------------------- glfwSwapBuffers(window); glfwPollEvents(); } // optional: de-allocate all resources once they\u0026#39;ve outlived their purpose: // ------------------------------------------------------------------------ glDeleteVertexArrays(1, \u0026amp;VAO); glDeleteBuffers(1, \u0026amp;VBO); glDeleteProgram(shaderProgram); // glfw: terminate, clearing all previously allocated GLFW resources. // ------------------------------------------------------------------ glfwTerminate(); return 0; } // process all input: query GLFW whether relevant keys are pressed/released this frame and react accordingly // --------------------------------------------------------------------------------------------------------- void processInput(GLFWwindow *window) { if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) glfwSetWindowShouldClose(window, true); } // glfw: whenever the window size changed (by OS or user resize) this callback function executes // --------------------------------------------------------------------------------------------- void framebuffer_size_callback(GLFWwindow* window, int width, int height) { // make sure the viewport matches the new window dimensions; note that width and // height will be significantly larger than specified on retina displays. glViewport(0, 0, width, height); } 之后可以自己随便找一个模型文件，通过以下代码看看assimp是否配置成功：\n#include \u0026lt;assimp/Importer.hpp\u0026gt; #include \u0026lt;assimp/scene.h\u0026gt; #include \u0026lt;assimp/postprocess.h\u0026gt; #include \u0026lt;iostream\u0026gt; int main() { Assimp::Importer importer; const aiScene* scene = importer.ReadFile(\u0026#34;path_to_your_model.obj\u0026#34;, aiProcess_Triangulate); if (!scene) { std::cerr \u0026lt;\u0026lt; \u0026#34;Error: \u0026#34; \u0026lt;\u0026lt; importer.GetErrorString() \u0026lt;\u0026lt; std::endl; return -1; } std::cout \u0026lt;\u0026lt; \u0026#34;Assimp OK, load model succeed.\u0026#34; \u0026lt;\u0026lt; std::endl; return 0; } 学习到新的内容就可以直接在demos下创建一个新的cpp文件之后重新加载cmake项目，不用清除掉之前的内容，可以随时回顾自己学到的东西，自己写的头文件就放入headers目录下。\n开始愉快地学习吧！\n","permalink":"https://hsiang0117.github.io/posts/clion_setup/","summary":"使用Clion配置OpenGL学习环境，包括glfw、glad、assimp库的完整配置流程，以及切换到VS工具链的详细教程。","title":"Clion配置OpenGL学习环境——glfw+glad+assimp"}]