把 VRChat 虚拟形象从「Very Poor」中拯救出来的记录 —— 与 AI 两人三脚、一路踩坑不断的轻量化调试日记
VRChatアバターを「Very Poor」から救い出すまでの記録 ― AIと二人三脚でハマり続けた軽量化デバッグ日記
引言
我平时在 VRChat 里使用的虚拟形象「Lapine」(WARPSTORE 制),完整规格版相当重量级,性能等级为 Very Poor。日常使用保持原样即可,但进入负载较重的世界时会被拦截,或给周围的人添麻烦,所以我决定作为另一个项目制作「轻量版」。命名为 Lapin(lightweight)。
方针从一开始就是这样定下的。
- 原创(完整规格版)保持原样,用于日常使用
- 轻量版则作为「负载过重时的保险」另行准备
- 工具为 Unity 2022.3.22f1 + VCC + AAO(Avatar Optimizer) + NDMF + Meshia Cascading Avatar Mesh Simplifier
- 实际作业是与 Claude Code 并肩推进的
本文是 2026 年 8 月下旬的作业记录,工具版本(AAO 为 1.9.17)与规格均为当时的情况。
先说结论:最终成功从 Very Poor 提升到了Poor。不过这条路比想象中要泥泞得多。本文就是这段记录。
起点的数字
作业开始时的性能统计(此时已经做过一定程度修改)大致如下。
| 項目 | 値 | 上限(Poor 基准) |
|---|---|---|
| 三角形 | 140,980 | 70,000 |
| 蒙皮网格渲染器 | 40 | 16 |
| 材质槽 | 57 | 32 |
| PhysBone 组件 | 37 | 32 |
| 骨骼 | 696 | 400 |
| 约束深度 | 101 | 100 |
| 纹理内存 | 191MB | 150MB |
如你所见,几乎所有项目都超过了上限。起步时就已经是确定 Very Poor 的状态。
这是作为本文起点的时点的 Unity 画面(8/26 17:19)。SDK 面板显示 Triangles 140,980、Skinned Mesh Renderers 40、Material Slots 57、PhysBone Components 37,全都超过上限。
再往前,这是作业第一天(8/25 早上)在实机上看到的性能显示。
工作第一天(8/25早晨)在实机上的显示。三角面数189,828/70,000、蒙皮网格39/16、材质槽67/32、PhysBone组件65/32。
第一道难关:“明明保存了却消失了”——Unity Prefab的陷阱
开始作业后最先撞上的,是一个看似不起眼却很棘手的问题。用AAO的MergeSkinnedMesh按组把衣服的各个部件合并起来,但好不容易做好的合并结果却没有保存到Prefab文件中这种情况发生了好几次。
用MergeSkinnedMesh按服装部件合并后的结果(Merged_Jacket、Merged_Sweater……)。
原因在于Unity的基本规格:“直接在场景中编辑Prefab Instance时,改动会作为‘覆盖(Override)’留在场景一侧,而不会反映到Prefab资源本体上”。即使按Ctrl+S保存了场景,如果不执行Prefab Instance的Overrides→Apply All,下次从别处加载该Prefab时就会回到修改前的状态。
更棘手的是,如果在伴随重新编译的操作(比如添加新脚本)之前忘了保存,辛辛苦苦做的工作就会整个消失。这种因“忘记保存”而导致的回滚,在整个项目中屡次复发(后文要说的Grove_Mesh风波,这也是其远因)。
最终最可靠的处理方式,是切换为不经过场景中的 Prefab Instance,而是在 PrefabUtility.SaveAsPrefabAsset 中直接写入 Prefab 资源的方式。采用这种方式,“Apply All 遗漏”这个概念本身就不会出现,因此从根本上变得安全了。
第二道难关:Triangle 完全不减少 ——“只注册了 20 个”的问题
为了把 Triangles 140,980 降到 70,000,引入了 Meshia Cascading Avatar Mesh Simplifier,并把目标设定为 65,000。但实际构建后发现,实测为 102,362。距离目标还差近 37,000。
引入 Meshia 后的构建。Triangles 102362、Skinned Mesh Renderers 18。未达到目标的 65,000。
调查原因后,发现了令人惊讶的事实。整个 Avatar 中明明存在 38 个 SkinnedMeshRenderer,但注册到 Meshia 的却只有 20 个。剩下的 18 个完全没有接受任何简化或合并,以全分辨率原样通过,被包含进了构建中。
罪魁祸首是 Meshia 的规格。RefreshEntries()只有在打开 Inspector 时才会执行,而且非激活对象从一开始就不会被 GetComponentsInChildren 拾取。也就是说,通过开关隐藏起来的服装变体(非激活的备用网格)被彻底漏掉了注册。
已登记的20个TargetTriangleCount合计起来恰好与65,000(目标值)一致,这一点成了该假设的决定性依据。“数字与理论值完全吻合”在这种调试中是最可信的证据。
梳理的结果表明,需要进行如下分类。
- 过去已经决定“不使用”的网格(Body_Mesh、Dress_Mesh等)→ 其中既有如预期仍为EditorOnly的,也有实际上被回滚了的(后述),两者混杂在一起
- 仍在使用的服装,但只是漏登记的(Boots、Legs_Socks系列的扁平版等)→ 按照过去的共识正式删除
- 眼睛、眉毛的换色功能(Eye_Mesh/Eyebrow_Mesh)→ 因为没有使用,维持现状(保持非激活状态)
由此得到了一个重要的教训。非激活的网格也会被VRChat的性能统计如实计入。 并不是“藏起来就轻了”。
第三道难关:头发不听使唤
PhysBone Components(物理运算组件)有37个(上限32个),其中54%是头发的PhysBone。于是开始用AAO的MergePhysBone将多个骨骼合并为一个,但在这里也并非一帆风顺。
首先,作为合并对象的PhysBone需要满足“拥有相同父级”这一条件,但头发的各缕发束生长在分散的位置,因此不满足该条件。于是决定使用Make Children of Me(创建一个新的共同父级并重新配置到那里)选项。
试着一构建,这次又因Radius(摇晃判定的粗细)的值在合并对象之间不一致这一错误而失败。明明事先已确认spring/stiffness/gravity一致,却唯独漏查了Radius。把这里统一后重新构建。
MergePhysBone因Radius不一致而停止时的NDMF控制台。
构建本身通过了,但这次外观坏了。刘海遮住眼睛碍事,侧发出现“蓬松地僵住”而无法活动的奇怪症状。原因是,Make Children of Me创建的新父对象没有成为Head骨骼的子级,也没有跟随头部的运动。“外观位置会被保持”这一说明终究只是重新配置那一瞬间的事,之后Head移动时会怎样则是另一个问题。修好这里后,头发终于能正常活动了。
PhysBone Components 最终从 37 个削减到 21 个,轻松低于上限 32。
PhysBone 整合刚完成后的 AAO 日志(8/26 19:37)。PhysBone 从 37 降到 21,SkinnedMeshRenderer 从 30 降到 10。此时纹理内存已降到 93.73MiB,但之后的 Material Slots 处理又会让它重新膨胀。
第四面墙:SDK 面板会撒谎(准确地说,是「在看别的东西」)
贯穿整个项目,最让我困惑的现象是:VRChat SDK 的「Review Any Alerts」面板中显示的数字,与实际构建结果并不一致。
例如,AAO 的优化日志里显示「SkinnedMeshRenderer 数量:38→18」,但同一画面上的 SDK 面板却显示「Skinned Mesh Renderers: 30」这种明显矛盾的数字。好几次都让我慌张地以为「变糟了!?」。
SDK 面板的显示。Triangles 185598,Skinned Mesh Renderers 39。
同一虚拟形象的AAO日志。多边形数从185598→178126,SkinnedMeshRenderer从39→38,骨骼从696→278,出现了与SDK面板不同的数字。
真相是这样的。使用NDMF(Non-Destructive Modular Framework)时,网格的合并和简化是非破坏性的,也就是说只在实际构建时临时应用。然而SDK的性能面板只是把场景上的原始状态原样传给AvatarPerformance.CalculatePerformanceStats这个函数,完全与NDMF的构建结果无关地运行。并不是面板坏了,而是“每次只是诚实地显示NDMF应用前的状态”。不过它也有仅会尊重并排除EditorOnly标签这种半吊子聪明的行为,所以更加剧了混乱。
为了应对这一点,Claude Code使用AvatarProcessor.ManualProcessAvatar(NDMF的内部API),对复制的虚拟形象实际应用完整管线,并制作了一个从处理后的实际网格直接计数的验证脚本。多亏了它,即使不实际上传到VRChat,也能确认准确的最终数值。我觉得在这整个项目里,它是低调却最有用的工具。
第五道墙:过去的决定多次“回滚”
在这个项目中,“明明之前已经这样决定了,却又变回去了”这一现象反复发生。
- Grove_Mesh(手套)明明之前已经决定「恢复为有效」,却不知为何又回退成了 EditorOnly(实际上是我自己先手动修正了,但由于 Claude Code 方面的调查时机问题,出现了短暂的错位)
- Boots_Mesh 等本应「已删除」的服装,EditorOnly 标签被去掉后又复活了
- Expression Wheel 菜单里还残留着已经不能用的「靴子」选项,选中后腿会消失,成了有实际危害的 bug
原因几乎全部都是第一道墙中说明的「忘记保存 Prefab」的另一种变体。从这次经验中学到的是,仅仅记录「过去决定过的事情」是不够的,还需要定期与实际状态进行核对。特别是像 Prefab 的标签这种「不起眼、直到外观受到影响才难以察觉的设置」,尤其需要注意。
第六道墙:Material Slots 的陷阱 ― 错误的工具,以及涂黑事件
在处理 Material Slots(需要从 57 减少到 32)时,首先从工具选型失误开始。AAO 里有一个叫Merge Toon Lit Material的组件,但它是AAO 独有的 ToonLit 着色器专用的,而这次的 Avatar 全都使用 lilToon 着色器,因此候选项为 0,根本没法用。正确来说需要使用另一个叫Merge Material的组件。
Merge Material只能合并「同一渲染器内、使用完全相同 Shader 资产的材质之间」。lilToon 的普通材质和描边用材质属于不同的 Shader 资产,因此即使名字相似也无法混合,这也是一个限制。
经过细致的分组与纹理图集化处理,Material Slots 从 57 减少到了 31。然而在真机上确认时,出现了胸口一部分被涂成全黑的问题。原因是,合并的材质中一方(Cloth_Hand_M)开启了 Alpha Mask 功能,另一方(Body_M,皮肤部分)则关闭了该功能,而Merge Material的规格是合并后强制统一为一套设置。结果,本应不透明的皮肤被应用了 Alpha Mask,变成了透明(黑透)。
这次修正没有继续深究,而是做出了撤回该合并这一安全侧的判断。
第七面墙:数字都齐了,实机外观却崩了
Triangles、Material Slots、PhysBone、Bones、Constraint Depth 这 6 个项目全部达标的时候,我以为「这样就完成了」。然而在真机上上传到 VRChat 确认后,综合等级依然是 Very Poor。
原因是纹理内存。实测发现它膨胀到了264.40MB(上限150MB)。原因在于,Merge Material的规格是:不仅会处理“显式设置的纹理属性”,还会把所有在不同源之间取值不同的纹理属性,无条件地按组的输出尺寸烘焙进去。“明明应该只指定了_MainTex,实际上却有3张以4096×4096烘焙了”——这种意料之外的膨胀在到处发生。
将各图集尺寸按原始纹理分辨率缩小,从264MB削减到121MB。本以为终于所有项目都通过了……结果这次又出现了3个外观问题:部分皮肤变灰、腿部重复显示、眼睛和嘴巴看起来被埋住。这些问题是由在轻量化过程中作为“不显眼部分”过度提高简化率的网格(睫毛、眼影贴花、头发、毛衣周围)造成的,通过调整优先级并恢复这些部分解决了。
从这一系列流程中得到的教训是,“性能统计全部通过”和“实际没有损坏”是两回事。不能只看数字就放心,必须夹入实机目视确认。
重大决断:放弃换装功能
回顾目前为止的工作,忽然注意到一件事。反复发生的故障(标签回滚、整合组不一致、与Expression Menu的一致性、alpha遮罩被涂黑)几乎全部源自“试图让多个服装模式并存”这件事。
于是转换方针。基于“日常使用用原创(满规格版),这个终究只是应对重度场景的轻量版”这一定位,决定在轻量版中把服装固定为1套,完全放弃换装功能。同时,原本用于穿脱夹克的“拉耳朵机关”也因不再有切换目标而判断为不需要,连同 Expression Menu、参数一并删除。
由于这一判断,按服装区分的 MergeSkinnedMesh 分组管理、Expression Menu 的一致性检查、与切换动画的对应关系等,至今让我们吃尽苦头的复杂性全都变得不必要了。从数字上看,Triangles、Material Slots、纹理内存也都产生了很大的余量。
放弃了 Medium —— 被烘焙进 FBX 的墙
在看到 Poor 的可能性之后,就冒出了“既然这样,能不能也冲一冲 Medium”的欲望。到这里才第一次直接读取 VRChat SDK 内的实际文件(Good_Windows.asset、Medium_Windows.asset),确认了 Good/Medium/Poor 各自准确的阈值。
结果发现,Constraint Depth(当前98,Medium基准为80以下)是绝对的阻塞项。这133个Constraint(RotationConstraint 97个、PositionConstraint 22个、ParentConstraint 14个)是直接烘焙进FBX文件的Humanoid兼容重定向用Rig,在Unity上编辑Prefab完全无法削减。要修正必须在原始3D软件中重做Rig本身,而那是远远超出本次范围的工作。
此外,作为次要发现,还确认了“表情的BlendShape数量与性能等级完全无关”。VRChat的性能评估schema中,本来就不存在评估BlendShape数量的项目。
把未达到Medium的项目列出来,阻塞项是以下3个。
| 項目 | 最終値 | Medium基准 | Poor基准 |
|---|---|---|---|
| Constraint Depth | 98 | 80以下 | 100以下 |
| Material Slots | 31 | 16以下 | 32以下 |
| PhysBone Components | 21 | 16以下 | 32以下 |
Constraint Depth 的阈值是 Good 50 / Medium 80 / Poor 100,就连 Poor 的标准也达到了 98,已经是极限了。而且这源自烘焙进 FBX 的 rig,所以在 Unity 侧无论如何都无法解决。Material Slots 和 PhysBone 虽然努力的话似乎还能压缩,但唯独 Constraint Depth 无法改动。
既然存在 Constraint Depth 这道“范围之外的墙”,那么无论怎样削减其他项目,都达不到 Medium。与其冒险强行推进、破坏当前稳定的状态,不如确定将 Poor 作为最终目标。
最終結果
经过一系列作业,最终能够做到这个程度。
| 項目 | 開始時 | 最终(达成 Poor 时) | 上限(Poor基准) |
|---|---|---|---|
| 三角形 | 140,980 | 65,000〜68,000程度 | 70,000 |
| 蒙皮网格渲染器 | 40 | 8〜10 | 16 |
| 材质槽 | 57 | 31 | 32 |
| PhysBone 组件 | 37 | 21 | 32 |
| 骨骼 | 696 | 176〜179 | 400 |
| Constraint Depth | 101 | 98〜99 | 100 |
| 纹理内存 | 191MB | 111〜121MB | 150MB |
另外,Constraint Depth 在 SDK 面板上直到最后都显示为 133(原因与第四面墙相同)。表中的 98〜99 是 NDMF 处理后的实测值。
VRChat 实机中的显示。PhysBone 组件 21/32,约束深度 99/100,骨骼数量 179/400。
而且在那之后,通过将服装固定为 1 套并删除机关,确保了比上述更多一层的余量(按 Triangles 换算预计还能再削减 1 万以上)。
回顾后学到的事
-
在使用 NDMF 的 Avatar 中,不要盲目相信 VRC SDK 面板上的数字。 实际数值应通过 NDMF 日志中处理后的值,或
AvatarProcessor.ManualProcessAvatar这样的手段来确认 - 非激活的网格也会被计入性能统计。“因为隐藏起来了所以很轻”是错误的
-
直接在场景中编辑 Prefab Instance,会成为忘记保存、改动被回滚的温床。 尽可能采用通过
PrefabUtility.SaveAsPrefabAsset直接写入 Prefab 资产的方式 - 对于AAO或Meshia这类自动化工具,也要以存在漏注册、看漏规格为前提去怀疑它。 “是否出现了与目标值完全吻合的数字”是识破漏注册的好线索。
- 材质合并(MergeMaterial)需注意合并对象的切换类属性(如Alpha Mask等)是否一致。 若不一致,功能会在看不见的地方泄漏出来。
- 即使所有数字都达标,也一定要在真机上目视确认。 性能统计与实际观感出现破绽是两回事。
- 有时也需要有重新审视性能目标本身的判断。 放弃换装功能这个决定,起初虽有抵触,但结果上它清除了多年来反复折磨这个项目的故障的根本原因。
- 烘焙进FBX中的约束(这次而言就是Constraint Depth)有时会成为仅靠在Unity上作业无法逾越的壁垒。 不要一味强追,应设定现实的目标。
虽然过程漫长,但能从Very Poor一路走到Poor,并且是以有依据、脚踏实地的方式抵达,我认为这是一次很好的经验。
本文转载自作者的博客 → 原文
来源:Qiita · Unity 开发社区 · qiita.com








