UE5 运行时武器合批:网格合并叠贴图图集的设计与实现

面向有 UE 渲染基础的中高级程序。全文以某上线过移动端的大型 FPS 里落地的一套运行时武器合批系统为参考实现,讲清它的整体流程、关键技术选型、以及实现层面的开发细节——怎么把一把由十几个部件拼出来的模块化武器,在运行时收敛成尽可能少的 draw call。


引子:一把枪为什么会画十几次

现代 FPS 的武器几乎都是模块化的:一把步枪是枪管、上/下护木、机匣、枪托、弹匣、握把、瞄具……十几个独立的 SkeletalMesh 部件,各自挂在武器骨架的不同插槽上,玩家换配件就是换其中某个部件。这套结构对 gameplay 和美术工作流都很友好,但对渲染很不友好:

  • 组件维度:每个部件是一个独立的 SkeletalMeshComponent,各自参与骨骼姿态计算、各自提交渲染。十几个组件意味着十几份组件级开销。
  • 材质维度:每个部件带自己的材质,每个材质是一个 Draw Call(以下统一写作 draw call)。一把枪十几个材质槽,就是十几次 draw call;屏幕上再站四五个队友,各持一把,光武器就能吃掉上百个 draw call。

移动端和低端 PC 对 draw call 数量极其敏感,这就是合批(batching)要解决的问题。而运行时武器合批不是一件事,是两层叠加的一套系统:

  1. 网格合并(mesh merge):把 N 个部件的 SkeletalMesh 合成单一一个合并网格资产 + 单一一个组件。收益是组件数、骨骼更新、以及一部分 draw call 的下降。
  2. 贴图图集(texture atlas):在网格合并之上,把 N 套贴图烘进一张图集,让合并网格的 N 个材质槽塌成 1 个。(下文说的”1 次 draw call”,都指主渲染路径下材质槽收敛为单一提交;shadow / depth / velocity 等 pass 各自另算。)

两层是叠加关系,跑在同一条构建流水线上,但各自独立可回退:图集合不了,不影响网格照样合。稳定的合批系统追求的不是 100% 成功率,而是在任意资源组合下都保住一部分收益——这条”失败优雅退化”的原则贯穿全文,也是它比”怎么调 MeshMerge API”更值得讲的地方。本文就沿着”请求一把武器合批 → 系统怎么一步步把它构建出来”这条主线,把每一层的流程、选型和实现细节讲透。

在进入正文前先划一条边界:这里讨论的是运行时动态武器/装备系统——武器要骨骼驱动、配件自由组合、材质随皮肤变化,所以 Nanite、静态实例化(ISM/HISM)、GPU Scene 这些面向静态网格的方案并不能替代这一层;Runtime Virtual Texture 解决的是超大世界的纹理虚拟化,也不解决”材质槽与 draw call 收敛”这个问题域。本文的落点始终是 SkeletalMesh 的网格合并与材质收敛。

运行时武器合批 · 资源处理全流程
图1|运行时武器合批的资源处理全流程。一把模块化武器(N 部件网格 + N 材质 + N 贴图)从请求提交、子系统调度、构建状态机,走到可合并性分析的三态分叉:失败则回退多组件(红),只能合网格走保底(黄),材质同构则走完整两层(绿)。绿色路径依次经过装箱、UV 烧录、图集绘制(第二层·贴图图集),与黄色路径在网格合并处(第一层·网格合并)汇合,再经材质回填、收尾,最终收敛为 1 合并组件 + 1 母材质 MID + 图集 RT。后文各节即沿此图逐阶段展开。

一、整体架构与流程:请求、调度、构建状态机

先建立全局视图。整套系统在职责上分三段:

上层武器逻辑 合批子系统 构建状态机
(WeaponModule) ──请求──► (MeshMergeSubsystem) ──驱动──► (MergeJob)
「这把枪要合批」 优先级队列 / hash 缓存 / 分帧 Init→Load→Build→完成

1.1 请求侧:一把武器怎么进入合批

上层武器逻辑在装配好一把模块化武器后,向合批子系统提交一次异步合批请求(RequestAsyncMerge),请求里带上:这把武器的部件 SkeletalMesh 列表、每个部件的材质覆盖信息、目标图集尺寸、以及一个关键的意图标志 bWantMergeTexture(这一趟到底要不要合图集,还是只合网格)。

子系统在这里做三件很重要的事,这也是为什么合批必须走”子系统 + 异步”而不是每把枪自己合:

  • hash 缓存与去重:同样配置的武器会算出同一个 mesh hash——hash 覆盖部件 mesh 列表、socket 配置、材质覆盖、图集尺寸档、合并模式这几项。子系统按 hash 缓存已构建好的合并网格,相同配置的武器直接复用,不重复合;多人同图、多个玩家用同一把枪时,这条省下的开销远比合批本身大。同一帧涌进来的相同 hash 请求,还会合并到同一个构建句柄上,避免同时构建两份。(换皮肤 = 材质覆盖变了 → hash 不同 → 走一次新的合并,不会串用旧图集。)缓存不能只进不出:实际项目里得配合资源生命周期管理——引用计数、LRU 淘汰、或场景卸载时清理,否则合并资源的缓存会无限增长。
  • 优先级队列:自己手里的武器(一人称)优先级最高,队友的、远处的依次降低。
  • 分帧处理:图集绘制会占 GPU/主线程时间,子系统用一个每帧上限(WeaponMerge.MaxAtlasPerFrame)把构建摊到多帧,避免一次涌入十几把枪造成卡顿。
请求调度
图2|合批子系统的请求调度。多把武器请求入队,按 hash 缓存查——命中直接复用已合网格,未命中按优先级(FPP>TPP>3P)排队、每帧放行 N 个进入构建;同帧相同 hash 还会并到一个构建句柄,不重复构建。

1.2 构建侧:一个状态机对象

每一次真正的构建,由一个构建对象(这里叫 MergeJob)承载,它内部是一个状态机,每帧 Tick 推进一个状态,状态之间用 SetState(new 下一状态) 切换,SetState(nullptr) 表示整个构建结束。状态流是:

Init ──► LoadSkeletalMesh ──► [LoadOverrideMaterial] ──► Build ──► 完成(SetState null)
  • Init:初始化,校验请求合法性。
  • LoadSkeletalMesh:异步把所有部件 SkeletalMesh 拉进内存(可能有的还没加载)。
  • LoadOverrideMaterial:如果请求里带了覆盖材质(皮肤等),异步把这些材质也加载好;没有就跳过直接进 Build。
  • Build:真正干活的状态——合骨架、合网格、合图集、回填材质、收尾。本文后面九成篇幅都在讲这个状态里发生的事。

把构建做成状态机而不是一个大函数,本质是因为它跨多帧、且每一步都可能在等异步结果(资源加载、贴图流送、RHI 初始化)。状态机让”等待”变成一个自然的 Tick 返回,下一帧再来,不用阻塞线程、也不用回调地狱。

构建状态机
图3|构建状态机。Init → 加载部件网格 →(可跳过的加载覆盖材质)→ Build → 完成;Build 内按 AtlasMode 分流为三态。收尾只调一次 InitResources 发起 GPU 初始化、不轮询,避免拖到构建超时。

1.3 Build 状态的总分支:WeaponMerge.AtlasMode 决定走多远

进了 Build 状态,第一个岔路口是全局开关 WeaponMerge.AtlasMode

  • Mode == 0只合网格。直接构建合并网格,每个部件保留自己的材质槽——组件数收益拿到,图集不做。
  • Mode == 2合网格 + GPU 图集。先做可合并性分析,再画图集,再构建网格,最后把图集回填材质。(Mode == 1 是历史上的 CPU 合图路径,运行时代价不可接受,实现里保留了桩但不启用。)

Build 状态 Tick 的骨架大致是这样:

void FMergeState_Build::Tick(FMergeJob* Setup)
{
if (CVarAtlasMode.GetValueOnAnyThread() == 0)
{
// 只合网格:构建 → 异步构建完成 → 收尾
if (BuildMesh(Setup) && Setup->MergedSkeletalMesh->IsAsyncBuildFinished())
ActiveStopBuildStateIfBuildSuccess(Setup);
return;
}
// 合图集:先分析可合并性,按三态结果分流
EMergeResult Result = AnalyzeMergeability(Setup);
switch (Result)
{
case EMergeResult::Unmergeable: // 完全合不了
ActiveStopBuildStateIfBuildFailed(Setup);
break;
case EMergeResult::Atlas: // 能合图集
DrawAtlasTextures(Setup); // 画图集
if (IsAtlasReady(Setup) && BuildMesh(Setup)
&& Setup->MergedSkeletalMesh->IsAsyncBuildFinished())
ActiveStopBuildStateIfBuildSuccess(Setup);
break;
case EMergeResult::MeshOnly: // 合不了图集,但能合网格
if (BuildMesh(Setup) && Setup->MergedSkeletalMesh->IsAsyncBuildFinished())
ActiveStopBuildStateIfBuildSuccess(Setup);
break;
}
}

注意这里有个设计上很关键的点:分析结果是三态而不是”能合/不能合”两态。MeshOnly 意味着”图集合不成,但网格照合”——这就是本文开头说的分层可回退在代码里的落点。后面第五节会专门讲这个三态。


二、第一层:网格合并

网格合并的目标:把 N 个部件 SkeletalMesh 合成单一一个合并网格资产(这里叫 MergedSkeletalMesh),挂在单一一个组件上。它要处理四类东西:骨架、插槽、骨骼映射、渲染数据。

2.1 骨架合并

N 个部件各有自己的 RefSkeleton(引用骨架)。合并网格需要一副能同时驱动所有部件顶点的骨架,所以要把 N 副骨架并成一副。系统提供两条路:

  • 普通合并(normal):以武器主骨架为基准,把各部件骨架里缺的骨骼补进来,按名字去重。
  • 自定义合并(custom):由上层显式给出一套目标骨架,各部件往里映射。装配复杂、需要精确控制骨骼顺序的场景走这条。

合并的产物之一是源→目标骨骼映射表BuildSrcToDestBoneMap):部件 A 的第 3 根骨,对应合并骨架里的第几根。这张表是后面搬运顶点蒙皮权重的基础——顶点的骨骼索引必须从”部件本地”重映射到”合并骨架全局”。

骨架合并
图4|骨架合并。N 副部件骨架按名去重并成一副合并骨架,产出源→目标骨骼映射表;顶点蒙皮索引据此从部件本地重写到合并骨架全局,否则蒙皮错乱。

2.2 插槽(socket)合并

武器的挂点(瞄具位、枪口、弹匣位……)以 socket 形式存在于各部件骨架上。合并后这些 socket 必须迁移到合并骨架上,并保持相对变换正确,否则瞄具挂偏、枪口火焰跑位。custom 和 normal 两路各有对应的 socket 迁移逻辑(AddCustomSocket / AddNormalSocket)。

2.3 渲染数据合并

这是网格合并的重头:把 N 个部件每个 LOD 的顶点缓冲、索引缓冲、蒙皮权重、section 信息,合并成合并网格的单一一份 LOD 渲染数据。要点:

  • 顶点搬运:逐部件把顶点位置、法线/切线、UV、蒙皮权重拷进合并缓冲,其中蒙皮的骨骼索引按 2.1 的映射表重写。
  • section 合并:每个部件的每个 section(一段用同一材质的三角形)汇入合并网格。合并网格的 section 会带一张映射信息,记录”这个 section 来自哪个部件、用合并材质还是原材质”——这张表是第一层和第二层的接缝,第六节详述。
  • 逐 LOD 处理:合并要对齐 LOD。系统取各部件 LOD 数的最小值作为合并网格的 LOD 数,逐级合并。这也解释了为什么可合并性分析里有一条”各部件 LOD 数必须一致”的硬校验——LOD 数对不齐,section 映射就没法在各级之间传递。

2.4 为什么网格合并要单独成一层

一个很自然的问题:既然最终要合图集塌成 1 个 draw call,为什么不把网格合并和图集合并做成一件事?

因为它们的成功条件不同,且网格合并的成功条件宽松得多。图集要求各部件材质高度同构(下一节详述),现实中一把枪里总有几个”另类”部件(比如一块程序化生成的镜片、一个特殊材质的战术灯)合不进图集。如果两层绑死,一个另类部件就会让整把枪退回”完全不合”。

分层之后:另类部件让图集降级为 MeshOnly,但网格照样合——组件数从十几个降到 1 个、骨骼更新合并、多数 section 仍走合并网格。第一层的收益是”保底收益”,不因第二层失败而丢失。这是整套系统稳健性的地基。

组件数收益
图5|网格合并的第一层收益。十几个独立 SkeletalMeshComponent 收敛为 1 个合并组件,组件级开销(骨骼更新+提交+剔除)随组件数线性下降;即使图集合不成(MeshOnly),这层收益照拿。

三、第二层:图集的技术选型

网格合并之后,合并网格上还挂着 N 个材质槽。要塌成 1 个,就要把 N 套贴图烘进一张图集,让所有 section 指向同一个母材质。核心的技术选型问题是:图集怎么烘?

3.1 三条路线的取舍

路线 A:CPU 逐像素拷贝。 把每张源贴图在 CPU 上解码成像素数组,按布局拷进一张大图的对应区域,再上传成一张贴图。

  • 优点:实现直白、平台无关。
  • 致命缺点:运行时贴图是各种 GPU 压缩格式(BC/ASTC…),CPU 侧要先解码;大图拷贝是纯 CPU 密集操作,会卡主线程;还要自己处理 mip。运行时(尤其移动端)基本承受不起。否决。

路线 B:引擎定制的 GPU 合并贴图类。 一些项目会在引擎里做一个定制的”可合并贴图”类型,对底层贴图资源做私有扩展,配合运行时压缩(RTC)直接产出压缩好的合并贴图。

  • 优点:产物是压缩贴图,显存友好,移动端理想。
  • 缺点:重度耦合引擎私有改动。这个定制类型和它依赖的运行时压缩链路是一整套引擎级魔改,维护、移植、升级的成本都很高,且和上层强绑定。作为”通用可复用”的方案,它太重了。

**路线 C:UTextureRenderTarget2D + Canvas 绘制(选定)。** 建一张 RenderTarget,用引擎的 Canvas 绘制 API 把每张源贴图按布局画进去,直接当图集采样。

  • 优点:纯上层 API,不修改引擎核心代码BeginDrawCanvasToRenderTarget + K2_DrawTexture 是公开接口,绘制在渲染线程提交、由 GPU 渲染管线执行,源贴图的解码由采样硬件负责(不需要 CPU 解码),实现轻量、可移植、易维护。(它仍然依赖 RenderThread、RHI、RenderTarget 生命周期这些底层设施,只是不需要动引擎核心。)
  • 代价:RT 是无压缩的,显存代价比路线 B 大。RenderTarget 走的是运行时资源路径,不经过离线纹理压缩管线,因此拿不到 BC/ASTC 这类压缩收益——这正是它和普通 UTexture2D 的关键差异。这是它最主要的权衡点,第十一节会算这笔账。

选 C,是在”实现成本/可维护性”和”显存代价”之间,对 PC 与开发期做的取舍:PC 显存相对宽裕,用无压缩 RT 换掉一整套引擎魔改的维护负担,是划算的;移动端 ship 时若显存吃紧,可以在 C 的基础上再叠 RTC 压缩,但那是独立的一环,不影响主链路。

那为什么不是 Texture Array / VT / Bindless? 高级读者会想到这些”纹理批量”手段,但它们解决的是不同的问题:

方案解决的问题收敛材质槽 / draw call?
贴图图集(本文)把 N 套贴图并进一张,让所有 section 共用一个材质✅ 是(本问题域目标)
Texture Array同尺寸纹理批量采样(一次绑定多层切片)❌ 否
Runtime Virtual Texture超大世界纹理的虚拟化 / 按需驻留❌ 否
Bindless Texture突破纹理绑定数量上限❌ 否

换个角度看,它们各管一件事,并非互相竞争:图集解决空间组织(把多张塞进一张),VT 解决纹理驻留(超大纹理按需加载),Texture Array 解决采样集合(一次采多层),Bindless 解决绑定数量(突破槽位上限)。都能和图集组合使用,但没有一个替代”把材质槽塌成一个”——那正是图集在这条链路里的职责。

图集方案对比
图6|图集三方案对比。CPU 逐像素拷贝 / 引擎定制合并类 / RT+Canvas——选定 RT+Canvas:纯上层 API、不改引擎核心、GPU 绘制,代价是无压缩 RT 的显存。

3.2 图集合并的完整数据流

选定 RT+Canvas 后,从”一把武器的部件材质”到”单一母材质 + 一张图集”,整条流水线是:

① 可合并性分析 AnalyzeMergeability
├─ 找母材质(多数派)
├─ 按材质参数名把源贴图分成若干类别组(BaseColor / Normal / …)
└─ 判定三态:能合图集 / 只合网格 / 完全合不了
② 装箱 PackAtlas
└─ 算出每张源贴图在图集里的归一化位置与缩放 → AtlasTransforms
③ UV 烧录 BakeSectionMapping
└─ 把归一化变换烧进合并顶点的 UV,section 指向母材质或原材质
④ 图集绘制 DrawAtlasTextures
└─ 每个类别组画一张 RT,Canvas 逐张画入,显式生成 mip
⑤ 材质回填 BindAtlasTextures
└─ 把画好的图集按参数名塞回母材质 MID

后面五节,就是把这五步逐一拆开。

图集数据流总览
图7|图集合并的完整数据流:可合并性分析 → 装箱 → UV 烧录 → 图集绘制 → 材质回填。参数名是全链路的接线端子,一路对齐。

四、可合并性分析:三态设计与母材质

图集能不能合,取决于这些部件的材质是不是”足够像”。可合并性分析(AnalyzeMergeability)就是回答这个问题,并为后续步骤准备好数据。它的产出是一个三态枚举:

enum class EMergeResult
{
Unmergeable, // 连网格都合不了(数据非法)
MeshOnly,// 合不了图集,但能合网格(保底路径)
Atlas, // 能合图集(最优路径)
};

4.1 为什么是三态

如果只用布尔”能合/不能合”,那”图集合不成”就只能退回”什么都不合”,第一层收益全丢。三态把”网格能不能合”和”图集能不能合”解耦:

  • Unmergeable:输入数据本身就不合法(没有部件、LOD 数对不齐),网格都没法合,直接失败。
  • MeshOnly:网格能合,图集合不成。走网格合并,各 section 保留原材质。这是保底路径,也是最能体现分层价值的一态。
  • Atlas:材质足够同构,图集能合,走完整两层。

代码里几乎每一个”合不了图集”的判据,落点都是 MeshOnly 而不是 Unmergeable——分析器的默认倾向是”尽量保住网格合并”。

三态判定决策树
图8|可合并性分析的三态判定。前两道非法输入 → Unmergeable;其余任一不满足 → MeshOnly(保底只合网格);全过 → Atlas。分析器默认倾向:能保住网格合并就不全盘放弃。

4.2 收集材质信息

分析的第一步是把所有部件、所有 LOD、所有 section 的材质信息摊平收集(ExtractMeshLODSectionsMaterialInfo)。对每个 section 要解析出它实际用的材质——这里有三层优先级:

  1. 请求里带的覆盖材质(override,比如皮肤)优先;
  2. 否则用 LOD 信息里的 LODMaterialMap 重定向后的材质索引;
  3. 拿到材质接口后,再往上找它的父材质当作”基材质”(base material)——因为两个部件可能用了同一个材质的不同实例(MI),参数不同但父材质相同,判同构要看父材质。

4.3 找母材质:多数派原则

一把武器十几个 section,材质未必完全一致。图集需要选一个”母材质”当作合并后所有 section 共用的那个。选法是多数派PickBaseMaterial):统计每个基材质在所有 section 里出现的次数,出现最多的那个当母材质。

TMap<UMaterialInterface*, int> MaterialsCntMap;
// 遍历所有 section,对每个 section 的基材质计数……
UMaterialInterface* RefMaterial = nullptr;
int RefBaseMaterialCnt = 0;
for (auto& Element : MaterialsCntMap)
if (Element.Value > RefBaseMaterialCnt) // 取出现次数最多的
{ RefMaterial = Element.Key; RefBaseMaterialCnt = Element.Value; }

多数派而非”必须全一致”,是又一处向”尽量能合”倾斜的设计:占多数的 section 用母材质进图集,少数另类 section 走原材质回退(在第六节体现为 SectionID = -1)。

选出母材质后,还要在用这个母材质的 section 里,挑一个贴图参数最多的材质实例当”参数模板”(BaseRefMaterialInstance)——用它来确定”这套材质到底有哪些贴图参数需要合”。如果连这个都找不到,直接降级 MeshOnly

4.4 按参数名分组,BaseColor 强制第 0 组

一个材质有多张贴图:BaseColor、Normal、Roughness/Metallic/AO(常打包成一张 NR 或 MAC)……图集不是把所有贴图混在一张里,而是按材质参数名,每类贴图各合一张图集:所有部件的 BaseColor 合成一张 BaseColor 图集,所有 Normal 合成一张 Normal 图集,以此类推。这样母材质的每个贴图参数,对应一张图集 RT。

代码里用”类别组”(category group)表达这个结构:每个类别组绑定一个材质参数(如 BaseColorMap),组里装着所有部件这一类的源贴图。

这里有个看似不起眼但很关键的细节——BaseColorMap 被强制排到第 0 组

if (MaterialParameterInfo.Name == TEXT("BaseColorMap"))
TexturesParameterInfo.Insert(MaterialParameterInfo, 0); // 插到最前
else
TexturesParameterInfo.Add(MaterialParameterInfo);

为什么?因为装箱和尺寸调整以第 0 组为基准。所有类别组共用同一套图集布局(BaseColor 在图集左上角,那么 Normal 图集的同一个部件也在左上角),布局只算一次、用第 0 组的贴图尺寸来算。BaseColor 通常是最能代表”这个部件该占多大”的贴图,拿它当基准最合理。所有类别组的槽位数、布局必须严格一致,否则 UV 变换就串位了——代码里专门有一段保证”各类别组占位一样”的补齐逻辑。

4.5 逐条回退判据

分析走到这里,会依次撞上几道闸门,任何一道没过就降级 MeshOnly

  • 没有母材质实例 → 无从确定贴图参数。
  • **没有可合的贴图参数组,或请求 bWantMergeTexture == false** → 上层这趟本就不想合图集(比如一人称近景保质量的分流,见第十一节)。
  • 可合的源贴图 ≤ 1 张 → 就一张贴图,合图集没意义(图集就是为了把多张塞一起)。
  • 装箱失败 → 尺寸放不下(见第五节)。

只有全过,才 Atlas。无论走哪个分支,最后都会调 BakeSectionMapping 把 section 映射和 UV 烧录做掉(第六节)——因为即使只合网格,section→材质的映射也是必须的。

4.6 把 LOD0 的结论传播到其它 LOD

分析只在 LOD0 上做(BaseLODIndex = 0),得出”哪个 section 用哪张图集贴图”。其它 LOD 不重新分析,而是按材质接口匹配,把 LOD0 的合并结论直接复制过去:LOD1 里某 section 如果和 LOD0 某 section 用同一个材质接口,就沿用它的图集索引和”是否参与合并”标记。这样保证同一部件在所有 LOD 上的图集映射一致,也省掉逐 LOD 重复分析。


五、装箱:把不同尺寸的贴图塞进一张图集

分析确定了”要合哪些贴图”,装箱(PackAtlas)确定”每张贴图在图集里占哪块、多大”。产物是每张源贴图的一个归一化变换 FVector4(ScaleX, ScaleY, CoordX, CoordY)——缩放和左上角坐标,全部归一化到 [0,1]。

5.1 先算面积,够不够放

图集是固定边长的方图(TargetMergeTextureSizeX,按视角档取 2048/1024)。装箱先算总面积够不够:

uint32 TargetArea = TargetSize * TargetSize; // 图集总面积
uint32 TextureTotalArea = Σ(每张源贴图 SizeX * SizeX); // 源贴图面积和

如果 TextureTotalArea <= TargetArea,皆大欢喜,直接放。放不下就要缩小部分源贴图——这是装箱里最有意思的一段。

5.2 两阶段减半策略

缩小不是无脑等比压,而是分两阶段,尽量少牺牲质量:

阶段一:优先把”超过自己配置默认尺寸”的贴图降回默认。 每张贴图有一个”配置默认尺寸”(美术给这个部件定的合理尺寸)和一个”减小优先级序”(ReduceTextureSizeOrder,哪些贴图可以先牺牲)。阶段一按优先级序,把那些当前尺寸高于其默认尺寸的贴图逐张减半,直到腾出足够空间。这一步砍的是”超规格”的冗余,几乎不损观感。

if (TextureTotalArea > TargetArea)
{
InTexReduceSize.Sort(CompareReduceOrder); // 按减小优先级排
int32 SpaceArea = TextureTotalArea - TargetArea;
// 阶段一:只减「当前尺寸 > 配置默认尺寸」的贴图
for (...; SpaceArea > 0; ...)
if (tex.SizeX > tex.DefaultSizeX && tex.DefaultSizeX > 0)
{
uint32 Reduced = tex.SizeX / 2;
SpaceArea -= (tex.SizeX*tex.SizeX - Reduced*Reduced);
tex.SizeX = Reduced; tex.MipIndex++;
}
...
}

阶段二:还放不下,全体轮流减半。 如果阶段一砍完超规格部分仍不够,就进入”由大到小排序后,全体轮流减半”的循环,直到塞下(带一个 trycnt < 100 的死循环护栏)。这一步开始动真格的质量了,但优先减大的,观感损失可控。

减半而不是任意比例缩放,是因为减半正好对应降一级 mip——源贴图现成的低一级 mip 就是它减半的结果,取用零成本,不需要重新缩放采样。代码里每减半一次就 MipIndex++,记的就是”取这张贴图的第几级 mip”。

两阶段减半
图9|装箱的两阶段减半。阶段一先把超规格贴图降回默认尺寸(几乎不损观感),仍放不下再全体轮流减半;减半正好取现成的低一级 mip,零重采样成本。

5.3 放置与归一化变换

尺寸定好后,交给一个矩形装箱器(FAtlasPacker,内部是从大到小放置 + 空间切分的经典 2D 装箱)算出每张贴图的左上角偏移。最后把”尺寸 + 偏移”归一化成变换:

float ScaleX = (float)AdjustSizeX / TargetSize; // 这张占图集宽的比例
float ScaleY = (float)AdjustSizeX / TargetSize;
float CoordX = (float)Offset.width / TargetSize; // 左上角归一化 x
float CoordY = (float)Offset.height / TargetSize; // 左上角归一化 y
AtlasTransforms.Add(FVector4(ScaleX, ScaleY, CoordX, CoordY));

这个 FVector4 就是装箱的最终产物,同时喂给两处:绘制时决定这张贴图画在 RT 的哪块(第七节),UV 烧录时决定原顶点 UV 怎么映射到图集里(第六节)。一份数据,两处消费,是这套设计干净的地方。


六、UV 烧录与 section 重映射

图集画好后,采样必须命中正确区域:原来部件 A 的 UV 是相对它自己那张 512 贴图的 [0,1],现在那张贴图被塞进了 2048 图集的左上角某块 512×512 区域,采样坐标必须相应缩放平移。有两种做法:

  • 改材质里的采样逻辑:给每个 section 传一组 UV 变换参数,材质里手动变换后采样。缺点是母材质要为此加参数、加指令,且每 section 一套参数就没法真正塌成一个 draw call。
  • 把变换烧进顶点 UV(选定):直接把每个顶点的 UV 预先变换好,写进合并网格的顶点数据。母材质纹面读到的 UV 已经是图集坐标,采样逻辑不用改,所有 section 共用同一个母材质 → 主渲染路径下收敛为单一 draw call。

选后者。烧录在 BakeSectionMapping 里完成,它同时干两件事:产出 UV 变换 + 建立 section→材质的映射

6.1 UV 变换:从归一化 transform 到顶点变换

对每个部件的每个 LOD 的每个 section,如果它参与图集合并(bUsedForMerging),就用装箱那步的 FVector4 构造一个 UV 变换:

if (bUsedForMerging)
{
LODMapping.SectionIDs.Add(0); // 指向合并材质(母材质 MID)
FVector Scale = FVector(Transform.X, Transform.Y, 1.0f); // ScaleX, ScaleY
FVector Translation = FVector(Transform.Z, Transform.W, 1.0f); // CoordX, CoordY
SectionUVTransform.Add(FTransform(FRotator::ZeroRotator, Translation, Scale));
}

这个 FTransform 会在渲染数据合并时作用到该 section 每个顶点的 UV 上:新UV = 旧UV * Scale + Translation。旧 UV 的 [0,1] 就被压进图集里那块归一化区域。

6.2 section 映射:0 走合并,-1 走原材质

注意上面的 SectionIDs.Add(0)——0 表示”这个 section 用合并材质(那唯一的母材质 MID)”。而合不进图集的另类 section,走 else 分支:

else
{
LODMapping.SectionIDs.Add(-1); // -1:不用合并材质,保留自己的原材质
// 把这个 section 的原材质加进"合并网格的附加材质表",并记录索引重映射
...
SkelMeshMaterials.MaterialIndexesRemapper.Add({OriginalIndex, RemapperIndex});
}

-1 是分层可回退在渲染数据层面的最终落点:合并网格上,绝大多数 section 指向 0 号母材质(合成一个 draw call),少数另类 section 各自保留原材质(各自一个 draw call)。于是 MeshOnly 状态下的合并网格,材质槽数 = 1(母材质)+ 少数另类——依然远少于原始的 N 个。图集降级不是”全有或全无”,是优雅退化。

section 映射
图10|section 映射。合并网格上多数 section 指向唯一母材质 MID(图集)合成一个 draw call,少数另类 section 保留原材质(SectionID=-1);draw call 数 = 1 + 另类数——图集降级是优雅退化。

七、图集绘制:RT 参数、Canvas 循环、伽马与 mip

到这里,”哪些贴图、各占哪块”都定了。这一节把图集真正画出来(DrawAtlasTextures),这也是开发细节最密集、最容易踩坑的一节。核心是三个技术点:RT 怎么建、怎么画、以及伽马和 mip 这两个每一步都能翻车的地方

7.1 每个类别组一张 RT

前面说过,图集按材质参数名分类别组,每组画一张 RT。对每个类别组:

UTextureRenderTarget2D* AtlasRT = NewObject<UTextureRenderTarget2D>(World, UniqueName);
AtlasRT->RenderTargetFormat = ETextureRenderTargetFormat::RTF_RGBA8;
AtlasRT->bAutoGenerateMips = true;
AtlasRT->SRGB = false; // ← 关键,见 7.3
AtlasRT->bForceLinearGamma = true; // ← 关键,见 7.3
AtlasRT->ClearColor = FLinearColor::Black;
AtlasRT->InitAutoFormat(TargetSize, TargetSize);
AtlasRT->UpdateResourceImmediate(true);

RT 名字带上参数名(AtlasRT_BaseColorMap 之类),纯粹为了调试时导出定位方便。格式选 RTF_RGBA8——8 位足够,显存翻倍的 16 位只在暗部 banding 真看得出时才对个别通道启用(见第十一节)。

7.2 Canvas 绘制循环

绘制用 Canvas,逐张源贴图按归一化变换画进 RT 对应区域:

UCanvas* Canvas; FVector2D CanvasSize; FDrawToRenderTargetContext Ctx;
UKismetRenderingLibrary::BeginDrawCanvasToRenderTarget(World, AtlasRT, Canvas, CanvasSize, Ctx);
for (int i = 0; i < SourceTextures.Num(); i++)
{
const FVector4& T = Transforms[i]; // ScaleX,ScaleY,CoordX,CoordY
FVector2D Pos (T.Z * TargetSize, T.W * TargetSize); // 归一化坐标 → 像素
FVector2D Size (T.X * TargetSize, T.Y * TargetSize); // 归一化缩放 → 像素
Canvas->K2_DrawTexture(SourceTextures[i], Pos, Size,
FVector2D::ZeroVector, FVector2D::UnitVector, // 采源贴图整张 [0,1]
FLinearColor::White, BLEND_Opaque); // 不透明覆盖
}
UKismetRenderingLibrary::EndDrawCanvasToRenderTarget(World, Ctx);

归一化变换在这里”落地”成像素坐标。BLEND_Opaque 是刻意的——图集各块互不重叠,直接覆盖写,不做混合。

7.3 伽马:为什么必须线性端到端

这是整套绘制里最隐蔽的坑,也是最值得讲清的开发细节。

贴图有两种颜色空间语义:BaseColor(反照率)这类”颜色”贴图存的是 sRGB 编码值,采样时硬件会自动解码成线性值给着色器;Normal/Roughness 这类”数据”贴图存的是线性值,采样时不解码。这个”存的时候编码、读的时候解码”的协议,靠贴图上的 SRGB 标记驱动。

问题在于:Canvas 画进 RT 这条路径,两端的 sRGB 协议必须一致,否则就多编码或多解码一次。 如果给图集 RT 挂上 SRGB = true

  • Canvas 采样 BaseColor 源贴图时,硬件已经把它解码成了线性值
  • 但写进带 sRGB 标记的 RT 时——实测这条 Canvas→RT 的写入路径并不会做 sRGB 硬件编码,它把线性值原样存了进去;
  • 然后材质采样这张挂着 sRGB 标记的图集时,硬件又解码一次——把本就是线性的值当成 sRGB 再解码,中灰被压暗。

后果是肉眼可见的表面泛白发灰、粗糙面变镜面。以一个 roughness 0.6 的表面为例,多做一次 sRGB 解码后数值被压到约 0.31,直接从哑光变成半反光。(roughness 本是线性数据、本不该经 gamma 编解码,这里的 0.6→0.31 只是用来说明”多一次 gamma 解码”造成的数值偏移,具体值取决于编码空间。)这是”读写两端 sRGB 协议不一致”导致的一次多余解码。

正确做法是线性端到端:RT 一律 SRGB = false + bForceLinearGamma = true。这样 Canvas 采样源贴图时按各自的标记正确解码成线性值,RT 原样存线性,材质原样读线性——在颜色空间转换链路上保持一致,避免额外 gamma 变换导致的系统性偏差(存储精度、format 转换等仍可能有小误差,但不再有系统性的整体偏移)。代价是 8 位线性存储在暗部有轻微 banding(第十一节给缓解方案),但正确性优先。

伽马链路对比
图11|伽马链路对比。RT 挂 SRGB=true 会让采样端多解一次码、中灰被压暗(roughness 0.6→0.31 为示意);线性端到端(SRGB=false + bForceLinearGamma)则链路值一致。

7.4 mip:为什么必须显式生成

第二个坑同样隐蔽。RT 建的时候设了 bAutoGenerateMips = true,直觉上 mip 会自动生成。但——**bAutoGenerateMips 在 Canvas 绘制路径上并不会被触发**。Canvas 只画了 mip0,下级 mip 停留在初始化时的清屏色(黑)。

后果:近看正常(采 mip0),一拉远就变黑。远处的武器采样低级 mip,读到的是全黑的反照率 + roughness=0,直接成了一把黑色镜面枪。

修法是画完 mip0 后显式跑一遍 mip 生成,用 RDG(Render Dependency Graph)把 RT 注册进去,调 FGenerateMips::Execute

ENQUEUE_RENDER_COMMAND(WeaponAtlasGenerateMips)(
[RenderTargetResource](FRHICommandListImmediate& RHICmdList)
{
FRHITexture* AtlasTexture = RenderTargetResource->GetRenderTargetTexture();
if (!AtlasTexture || AtlasTexture->GetNumMips() <= 1) return;
FRDGBuilder GraphBuilder(RHICmdList);
FRDGTextureRef Ref = RegisterExternalTexture(GraphBuilder, AtlasTexture, TEXT("WeaponAtlasRT"));
FGenerateMips::Execute(GraphBuilder, GMaxRHIFeatureLevel, Ref, FGenerateMipsParams{});
GraphBuilder.Execute();
});

这段在渲染线程上执行,把完整 mip 链补齐。补上之后,远景采样正常降 mip,黑枪消失。

要落到多平台还得确认一件事:FGenerateMips 依赖目标 RHI 对 RenderTarget mip 生成的支持(UAV 写入、filter 采样、以及具体 RT 格式的可用性)。这条在 PC 上一般无碍,移动端尤其要按目标机型的 RHI 实测验证,别默认它到处都能跑。

mip 缺失
图12|mip 缺失。bAutoGenerateMips 在 Canvas 绘制路径不触发,下级 mip 停在清屏黑 → 远景采到黑枪;画完显式跑 FGenerateMips(RDG)补齐 mip 链即修。

7.5 一个附带的验收工具

绘制路径里留了个开关(WeaponMerge.DumpAtlasRT),开启后每画完一张图集就把 RT 导成 PNG 落到本地。合批这种”画在 GPU 上、肉眼看不到中间产物”的系统,有个能直接把图集 dump 出来看布局对不对、有没有串位/泛白/黑块的开关,验收效率完全不一样。这类”可观测性开关”值得在任何离屏渲染系统里预留。


八、流式贴图与”先烘后精修”

前面绘制那节默认了一个前提:源贴图当下就在显存里、能采到清晰 mip。现实里未必——引擎的贴图流送(texture streaming)会按距离/重要度动态加载贴图 mip,一把刚拾取的武器,它的高清贴图可能还没流进来。这引出一个时序设计问题。

8.1 两难:等质量 vs 保时序

直觉方案是”等贴图流够了再画图集”。但这样做会堵住整条构建流水线:图集画不完,网格就构建不了(Build 状态卡在图集这步)。而合批请求是有时间预算的——子系统对一次构建有超时(约数秒),超时就判定这单失败、武器退回散件。一旦某把武器的贴图流送慢,整个合批就报废。

所以定下一条硬原则:贴图质量(精修)绝不能阻塞网格合并的构建时序。 时序是第一位的,质量是可以后补的。

8.2 先烘后精修

据此设计成两段:

  1. 立即出图(不阻塞):构建当下,用源贴图当前已驻留的 mip 立即画一版图集,不管它是不是满血。哪怕只有低清 mip,也先画出来,让构建流水线继续走完、网格按时合成。
  2. 延迟精修(异步):如果发现有源贴图没流够,挂一个 ticker,之后每隔 0.25s 检查一次,等所有源贴图都流够了,把图集重画一遍
// 立即用当前驻留 mip 画;同时催流送
for (每张源贴图) SourceTexture->SetForceMipLevelsToBeResident(30.0f);
DrawAtlasGroup(World, AtlasRT, SourceTextures, Transforms, TargetSize); // 先烘
if (!bAllSourcesReady)
ScheduleRefine(World, RefineGroups, Transforms, SlotSizes, TargetSize); // 后精修

重画只改 RT 的像素,不碰网格、不碰材质——武器表现为”刚出现时略糊,随即变清”,和 UE 普通贴图流送的观感基本一致,玩家无感。ticker 带一个 60s 的放弃阈值,防止某张贴图永远流不进来导致 ticker 泄漏。

值得单独点出:这其实是一种通用的异步质量分层策略——任何依赖异步资源就绪的 runtime pipeline,都值得把”可用结果”和”最佳结果”分开:先用手头能拿到的产出一个可用版本、不阻塞主流程,等资源就绪再异步替换成最佳版本。武器图集只是它的一个实例。

先烘后精修时序
图13|先烘后精修的时序。构建有超时红线:等贴图流够再合会撞线报废;正确做法是用驻留 mip 立即烘图、网格按时合成,源贴图流够后异步重画精修(只改 RT 像素,不占构建时序)。

8.3 就绪判据:不能用 IsFullyStreamedIn

“源贴图流够了没”这个判断,有个陷阱。直觉会用引擎的 IsFullyStreamedIn(),但它对带可选 mip(optional mips)的贴图恒返回 false——那些最高级 mip 被标为可选、可能永远不加载,IsFullyStreamedIn 就永远不满足,ticker 永远等下去。

正确的判据不是”满血”,而是”够画进这个槽位就行“:这张贴图在图集里只占某个尺寸的槽位,采进去也只会用到那一级分辨率,不必等到 mip0。所以判据是”当前驻留 mip 的顶层尺寸 ≥ 图集槽位尺寸”:

static bool IsSourceReady(UTexture2D* Src, uint32 SlotSizeX)
{
if (!Src) return true;
const int32 NumMips = Src->GetNumMips();
const int32 ResidentMips = Src->GetNumResidentMips();
// 已驻留 mip 里最高清那一级的边长
const int32 ResidentTopSizeX = ResidentMips > 0
? FMath::Max<int32>(Src->GetSizeX() >> (NumMips - ResidentMips), 1) : 0;
// 这张贴图在图集里实际需要的边长(不超过它自身尺寸)
const int32 WantedSizeX = FMath::Min<uint32>(SlotSizeX, Src->GetSizeX());
return ResidentTopSizeX >= WantedSizeX;
}

这个判据把”就绪”和”实际需求”绑定,既不会因为可选 mip 永远等下去,也不会在明明够清晰时还傻等满血——是这套流式适配里最精炼的一处。


九、材质回填与状态机收尾

图集画好了、UV 烧好了,最后一步是把它们接到一起,并把状态机干净地收掉。

9.1 创建母材质 MID 并回填图集

绘制那步末尾,从母材质创建了一个动态材质实例(MID),挂到合并网格上当唯一使用材质:

Setup->MergedMID =
UKismetMaterialLibrary::CreateDynamicMaterialInstance(World, BaseMaterial);
Setup->MergedSkeletalMesh->MergedMaterial = Setup->MergedMID;

然后在网格构建完成后,把每张画好的图集,按参数名塞回这个 MID(BindAtlasTextures):

for (每个类别组 i)
MID->SetTextureParameterValue(
CategoryGroup[i].MaterialParameterInfo.Name, // "BaseColorMap" / "NormalMap" / ...
AtlasRTs[i]); // 对应的图集 RT

参数名是这里的”接线端子”:分析按参数名分组、绘制按组产出 RT、回填按名塞回——名字一路对齐,图集就正确地接到了母材质对应的贴图槽上。回填有个前置校验(图集数必须等于类别组数)防止错位。

材质回填
图14|材质回填。画好的图集 RT 按材质参数名塞回母材质 MID 的对应贴图槽;回填前校验图集数=类别组数,防止错位串槽。

9.2 状态机收尾:只 InitResources 一次,不轮询

最后是发起合并网格的 GPU 资源初始化并结束状态机。这里有一个容易写错的时序细节:

void ActiveStopBuildStateIfBuildSuccess(FMergeJob* Setup)
{
Setup->MergedSkeletalMesh->InitResources(); // 发起一次,就够了
// ……通知上层合并完成……
Setup->SetState(NULL); // 结束状态机
}

关键是 InitResources() 只调一次就结束,不去轮询”资源初始化好了没”。因为 RHI 资源的初始化是丢到渲染线程异步做的,游戏线程这边轮询”初始化完成标志”会一直空转——而空转就会拖到构建超时。正确姿势是:游戏线程确认异步构建的数据就绪后,调一次 InitResources 把 GPU 初始化发起出去,剩下的交给渲染线程,渲染代理会在渲染线程处理完后自动就绪。这与引擎标准网格合并结束流程的处理是一致的——发起,然后放手,不要在游戏线程等一个属于渲染线程的事件。


十、把两层串起来看一遍

把前面九节压缩成一条端到端的路径,一把武器从请求到画出来:

  1. 上层装配好模块化武器,向合批子系统提交异步请求,带部件列表、覆盖材质、bWantMergeTexture
  2. 子系统查 hash 缓存:命中直接复用;否则入优先级队列,按每帧上限放行。
  3. 构建对象状态机启动:Init → 加载部件网格 →(加载覆盖材质)→ Build。
  4. Build 看 WeaponMerge.AtlasMode:只合网格就直接构建;要合图集则先 AnalyzeMergeability
  5. 分析得三态:非法 → 失败;同构 → Atlas;部分同构 → MeshOnly(保底只合网格)。
  6. 若能合图集:装箱算布局 → 烧 UV + section 映射 → 画图集 RT(线性端到端 + 显式 mip,源贴图没流够就先烘后精修)。
  7. 构建合并网格(骨架/socket/骨骼映射/渲染数据合并)。
  8. 创建母材质 MID,按参数名回填图集。
  9. InitResources 发起 GPU 初始化,通知上层,状态机结束。

结果:十几个部件、十几个组件、十几个材质槽的一把武器,收敛成 1 个合并组件 + 1 个母材质(另类 section 各加一个)+ 一组图集 RT。


十一、收益、代价与配置策略

技术讲完,最后算账——合批不是”开了就好”,是一组要按平台权衡的选择。

11.1 真实收益与代价

把开头讲的那几笔开销闭环起来看,一把武器合批前后的变化:

指标变化说明
组件数量N → 1十几个 SkeletalMeshComponent 收敛为 1 个合并组件
骨骼更新 / 提交 / 剔除多组件 → 单组件组件级开销随组件数线性下降
draw call−15%一个典型场景从约 394 降到约 337;武器越多、屏幕上人越多越显著
显存增加图集是无压缩 RT,见下

显存这笔要单算:一张 2048 的 RGBA8 带完整 mip 约 21.8MB,一把武器的五张图集(BaseColor / Normal / Roughness / Metallic / AO)约 109MB;降到 1024 约 27MB。

而且合批本身不是免费的:一次合并有构建成本(分析、装箱、绘制、GPU 资源初始化)和常驻内存(合并网格 + 图集 RT)。所以它只对高频出现、长期存在、或远处大量复用的对象划算——玩家持有十分钟的主武器值得合,掉在地上两秒就消失的战利品枪不值得。”要不要合”本身就是这套系统要做的判断。

这就是选型时反复提到的那笔账:RT+Canvas 方案拿显存换了实现成本和可维护性。组件与 draw call 一起降,显存单向涨——也因此有了下面这套按需分档的配置策略。

11.2 按视角分尺寸档

不是所有武器都值得 2048 图集。按”离镜头多近、多重要”分三档:

尺寸谁用理由
FPP2048一人称自己的武器贴脸,最清晰
TPP1024三人称本地玩家武器中景
3P1024其他玩家的武器最远、数量最多,最该省

远处的、别人的武器用小图集,把显存花在玩家最看得见的地方。

11.3 一人称是否合图集:一个近景质量权衡

还有一个分流开关(DisableFPPAtlas)控制”一人称武器要不要合图集”。默认一人称不合图集——因为一人称武器贴脸,是玩家全程盯着的画面主体,图集带来的任何精度损失(尺寸调整、8 位量化)都会被放大;而一人称武器就一把,合它省的那点 draw call 意义不大。近景保质量,远景保性能,是这条分流的逻辑。(此时上层就把 bWantMergeTexture 传 false,分析器在第四节那道闸门直接降级只合网格。)

11.4 无压缩 vs 运行时压缩:一个真实的产品权衡

最后一个更大的取舍:为什么这套图集在移动端 ship 时会开、而在 PC 上可以不开?

因为移动端显存是硬约束。移动端要开图集,就必须叠加运行时纹理压缩(RTC)把无压缩 RT 压成 ASTC 之类,否则 109MB/武器的显存曲线根本扛不住——RTC 是移动端开图集的硬门槛。而 PC 显存相对宽裕,无压缩 RT 的显存代价能吃得下,是否用图集换那 15% draw call,取决于目标机型的 draw call 瓶颈有多紧——draw call 不是瓶颈的高端 PC,为省它去付 100MB+/武器的显存未必划算。

合批不是越多越好,是沿着目标平台的 draw call/显存曲线找那个平衡点。 同一套技术,移动端是”必须开且必须叠压缩”,PC 是”可选,看瓶颈”——这才是工程决策的真实样子。

收益代价权衡
图15|收益与代价的平台权衡。移动端必须开且叠 RTC(显存硬约束),PC 可选(看 draw call 瓶颈);一把武器五张图集 2048≈109MB / 1024≈27MB,draw call 同景约 −15%。

结语:三条可迁移的设计原则

抛开具体 API,这套运行时武器合批里真正可复用的,是三条设计原则:

  1. 分层,且每层可回退。 网格合并和图集是两层,用三态分析把它们解耦,图集失败优雅退化为只合网格,保底收益不丢。别把强条件(材质同构)和弱条件(网格可合)绑死。
  2. 质量精修永远不许阻塞关键时序。 构建有时间预算,就先用手头资源立即出结果,质量异步后补(先烘后精修)。把”正确但慢”和”够用且快”分开,让快的那条先跑通。
  3. 离屏渲染要守住颜色空间和 mip 的完整性。 RT 图集全程线性端到端(读写两端 sRGB 协议一致),Canvas 路径的 mip 必须显式补齐。这两个是”用上层 API 拼渲染管线”时最容易静默出错、且只在特定视角/距离才暴露的地方。

这些都不是武器、也不是某个引擎版本独有的——凡是”运行时把一堆资源合成更少 draw call”的系统(角色装备合批、UI 图集、植被合并……),都会撞上同一组问题。


AI 协作复盘

  • AI 帮了什么:通读整套合批插件与状态机源码、把散在多个文件里的流程串成一条端到端主线、把每个技术点(三态分析、两阶段装箱、UV 烧录、伽马链路、先烘后精修、显式 mip)对着代码讲到实现级、并整理成这篇结构化技术文。
  • 哪里需要人补位:真机收益数字(draw call、显存)、近景质量取舍、移动端 RTC 硬门槛这类产品级权衡,源码里读不出来,靠工程实测和平台经验拍板;文章的取舍口径(讲流程/选型/细节、不讲迁移踩坑)也由人定向。
  • 怎么协作:AI 负责”把代码里发生的事讲准、讲全”,人负责”决定哪些值得讲、以及背后的产品判断”——技术骨架交给 AI 通读整理,价值判断留给人。AI 尤其适合跨文件的信息整合(把散在多个文件里的调用链拼成一条主线);但一旦涉及性能收益数字和平台约束,仍要靠工程实测来验证,不能只凭读码下结论。

发表评论

了解 AI Native Game Development 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读