跳到正文
Qiita · Unity 开发社区· 来源· yuki_engineer08·· 1 天前AI 评分60

把 VRChat 虚拟形象从「Very Poor」中拯救出来的记录 —— 与 AI 两人三脚、一路踩坑不断的轻量化调试日记

VRChatアバターを「Very Poor」から救い出すまでの記録 ― AIと二人三脚でハマり続けた軽量化デバッグ日記

正文 · 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のVRChat SDKパネルとNDMFコンソール。Triangles 140,980、Skinned Mesh Renderers 40、Material Slots 57が赤字で表示されている

这是作为本文起点的时点的 Unity 画面(8/26 17:19)。SDK 面板显示 Triangles 140,980、Skinned Mesh Renderers 40、Material Slots 57、PhysBone Components 37,全都超过上限。

再往前,这是作业第一天(8/25 早上)在实机上看到的性能显示。

VRChat実機のアバターパフォーマンス表示。テクスチャメモリ191.04MB、三角ポリゴン189,828/70,000、スキンメッシュ39/16、マテリアルスロット67/32、PhysBoneコンポーネント65/32がいずれも赤字

工作第一天(8/25早晨)在实机上的显示。三角面数189,828/70,000、蒙皮网格39/16、材质槽67/32、PhysBone组件65/32。

第一道难关:“明明保存了却消失了”——Unity Prefab的陷阱

开始作业后最先撞上的,是一个看似不起眼却很棘手的问题。用AAO的MergeSkinnedMesh按组把衣服的各个部件合并起来,但好不容易做好的合并结果却没有保存到Prefab文件中这种情况发生了好几次。

Unityのヒエラルキー。Merged_Jacket、Merged_Sweater、Merged_Choker、Merged_Belt、Merged_Ear、Merged_Boots、Merged_HighHeelsなど、MergeSkinnedMeshで統合したオブジェクトが並んでいる

用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。

VRChat SDKのReview Any Alertsパネルと、AAOの統合警告ログ。Triangles 102362、Skinned Mesh Renderers 18、Material Slots 56と表示されている

引入 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。把这里统一后重新构建。

NDMFコンソールのエラー表示。MergePhysBone MergedPB_HairMain について「複数の統合対象の間で値に差異があります。以下の値は同じ値にするかOverrideする必要があります: Radius」と出ている

MergePhysBone因Radius不一致而停止时的NDMF控制台。

构建本身通过了,但这次外观坏了。刘海遮住眼睛碍事,侧发出现“蓬松地僵住”而无法活动的奇怪症状。原因是,Make Children of Me创建的新父对象没有成为Head骨骼的子级,也没有跟随头部的运动。“外观位置会被保持”这一说明终究只是重新配置那一瞬间的事,之后Head移动时会怎样则是另一个问题。修好这里后,头发终于能正常活动了。

PhysBone Components 最终从 37 个削减到 21 个,轻松低于上限 32。

NDMFコンソールのAAO最適化結果。テクスチャメモリ 93.73MiB→93.71MiB、SkinnedMeshRendererの数 30→10、PhysBoneの数 37→21、ボーンの数 696→176、Constraintの深度 133→98

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」这种明显矛盾的数字。好几次都让我慌张地以为「变糟了!?」。

UnityのVRChat SDKパネル。Triangles 185598、Skinned Mesh Renderers 39と表示されている。左のコンソールにはBuild Succeededの文字

SDK 面板的显示。Triangles 185598,Skinned Mesh Renderers 39。

NDMFコンソールのAAO最適化結果。ポリゴンの数 185598→178126、SkinnedMeshRendererの数 39→38、ボーンの数 696→278

同一虚拟形象的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

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