Unity 蒙皮包围盒与展示模型自适应缩放

做图鉴和排行榜展示时,我需要把角色与手持模型放进一个固定大小的展示空间。不同服装、不同形状的手持物可以组成很多种组合,逐个配置缩放很难维护,所以我根据模型的包围盒自动限制整体尺寸。

随后遇到了一个奇怪的问题:有的角色会被缩得非常小,看起来像是展示空间里只剩一个小点。缩放公式没有明显错误,真正出问题的是公式拿到的“模型尺寸”。

先明确要测量什么

展示功能需要的是当前参与展示的模型,在指定姿势下占据的空间。它与网格资产的原始尺寸、导入时覆盖多段动画的包围盒,都不一定相同。

Unity 中几个容易混用的数据各有用途:

数据 坐标空间 用于展示时需要注意什么
Mesh.bounds 网格局部空间 蒙皮网格的原始顶点范围不代表当前动画姿势
SkinnedMeshRenderer.localBounds Renderer 局部空间 可能是较保守的动画包络,不一定紧贴当前外形
Renderer.bounds 世界空间 AABB 受旋转、蒙皮更新和剔除状态影响
BakeMesh 得到的网格 Renderer 的局部空间 是当前蒙皮姿势的快照,仍需正确处理坐标变换

第一次排查时,画出来的包围盒远大于实际人物。尺寸限制逻辑把这个虚大的盒子当成真实模型,只能把角色继续缩小。

编辑器中的人物与明显偏大的包围盒

裁取自当时的 Scene 视图,只保留人物与包围盒的大小关系。

把相机拉远并不能修正这个问题。相机改变的是画面投影,错误的尺寸测量仍然会让缩放逻辑把角色压到不合适的大小。

第一次尝试使用 BakeMesh

SkinnedMeshRenderer.BakeMesh 可以得到当前蒙皮姿势的网格快照。当静态包围盒过于保守时,它是一个有用的排查入口:比较烘焙网格与 Renderer 的范围,可以帮助判断差异来自资源包络还是当前姿势。

不过,烘焙之后不能直接把 mesh.bounds.size 当成世界尺寸。对于一般层级,需要把局部包围盒的八个角转换到同一坐标空间,再合并求得新的 AABB。

局部网格包围盒转换为世界空间包围盒后,再参与统一缩放

这里有两个常见误区:

  • 只把局部 size 乘以 lossyScale,忽略了旋转对世界 AABB 的影响。
  • 使用了带缩放的烘焙结果,又在后面的矩阵变换中重复应用同一层缩放。

我们项目还有额外情况:服装的骨骼会重绑到角色骨架,Renderer 所在节点的缩放与实际蒙皮表现不总能用简单的层级关系对应。继续套用“局部盒乘 Transform”得到的结果,可能退化成点,也可能异常放大。

因此我没有把 BakeMesh 固定成最终方案,而是继续检查项目自己的蒙皮更新过程。烘焙接口能够提供信息,但不能代替对骨架和坐标空间的确认。

尺寸正确还不够,测量时机也要正确

第二个问题出现在模型加载期间。角色、服装和手持物分别就绪,Animator 还可能因为资源切换执行 Rebind。这时如果立即测量,拿到的可能是绑定姿势或尚未稳定的骨骼状态。

同一套资源有时正常、有时异常,正是这类时序问题的典型表现。于是测量流程调整为:

  1. 等待角色、服装和手持物全部就绪。
  2. 完成动画控制器与骨架的刷新。
  3. 跨过重绑当帧,在后续更新阶段评估展示姿势。
  4. 确认蒙皮已更新,再收集实际参与展示的 Renderer。
  5. 合并包围盒并计算统一缩放。

对这个专用展示角色,我在测量前把 Animator 评估到确定的 Idle 姿势。核心操作如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var previousMode = animator.cullingMode;
try
{
animator.cullingMode = AnimatorCullingMode.AlwaysAnimate;

if (animator.HasState(0, idleStateHash))
animator.Play(idleStateHash, 0, 0f);

// 写回这一姿势,但不推进动画时间。
animator.Update(0f);
}
finally
{
animator.cullingMode = previousMode;
}

这段代码会改变当前动画姿势,所以适用于初始化中的展示角色,不应直接拿去重置正在游玩的角色。只量 Idle 也只能保证该姿势的尺寸,不能保证后续挥手、跳跃等所有动画始终装在同一个空间里。

离屏更新也是测量条件

展示模型常在画面外完成加载。即使资源已经存在,离屏剔除仍可能让动画或蒙皮没有按预期更新。

在这次实现中,我为相关 SkinnedMeshRenderer 开启了 updateWhenOffscreen,结合确定的 Animator 姿势和后续帧的测量,取得项目中可用的世界空间包围盒。这样也避免了自行还原特殊蒙皮层级的世界盒。

1
2
3
4
if (renderer is SkinnedMeshRenderer skinned)
skinned.updateWhenOffscreen = true;

Bounds worldBounds = renderer.bounds;

这里不能理解为“开一个开关后,所有模型的 Bounds 都会立即变准确”。还要确认项目是否手动覆盖过 Bounds、骨架是否已经稳定,以及本帧的动画和蒙皮评估是否真的完成。

持续开启离屏更新会增加开销。我们的用途是数量有限的展示对象,这个取舍可以接受;如果把方案推广到大量角色,则应当限定更新对象与时段,并在恢复剔除后检查结果。

只合并真正参与展示的对象

角色下面不一定只有可见的服装。隐藏的旧部件、未激活的手持物和粒子特效,都可能把包围盒撑大。

因此收集时需要先过滤:

1
2
3
4
5
if (!renderer.enabled || !renderer.gameObject.activeInHierarchy)
continue;

if (renderer is ParticleSystemRenderer)
continue;

剩余对象统一使用世界空间数据,通过 Bounds.Encapsulate 合并。第一个有效 Renderer 用来初始化合并结果,不能用世界原点上的零盒子起步,否则远离原点的模型会把无关空间也算进去。

计算时我还记录每个轴的最小值和最大值分别来自哪个 Renderer。出现异常时,就能直接知道是某件服装撑大了 X 轴,还是某个骨骼关联部件把 Y 轴拉长,而不是只看到一个最终的巨大数字。

根据限制最紧的轴统一缩放

设当前模型尺寸为 measuredSize,允许展示的空间尺寸为 targetSize。如果只希望压小超出范围的模型,而不放大小模型,可以这样计算倍率:

1
2
3
4
5
6
7
float fit = Mathf.Min(
1f,
targetSize.x / measuredSize.x,
targetSize.y / measuredSize.y,
targetSize.z / measuredSize.z);

modelRoot.localScale = initialScale * fit;

实际计算前还要剔除空集合、零尺寸和非有限数值。测量是在什么初始缩放下完成的,也必须保持一致,不能在已经缩过一次的模型上不断重复乘倍率。

统一缩放可以维持人物与手持物的相对比例,但形状较扁、较宽的物体往往会先触及水平限制。此时可以调整展示空间或按资源类别配置目标盒,不宜为了某一个模型直接放宽所有展示内容。

另外,这里解决的是三维空间尺寸约束。若要求模型在透视相机的 UI 窗口中始终完整可见,还需要考虑视角、FOV 和屏幕投影,世界 AABB 本身不是“画面一定不裁切”的保证。

排查中最有用的几项信息

这次从 6 月中旬的包围盒异常,逐步查到骨架重绑、离屏更新和展示测量时序。对同类问题,我会先记录四件事:当前姿势、参与合并的 Renderer、各轴极值来源,以及测量发生在哪个加载阶段。

只调整最终缩放比例,很容易把异常暂时藏起来。先确认测到的是哪个对象、哪一帧、哪个空间里的尺寸,后面的适配公式反而很简单。

参考:Renderer.bounds、SkinnedMeshRenderer.BakeMesh、updateWhenOffscreen。