挤压与拉伸:非均匀缩放为什么不能穿过旋转传下去
挤压与拉伸:非均匀缩放为什么不能穿过旋转传下去
站内搜索
直接问 AI

挤压与拉伸:非均匀缩放为什么不能穿过旋转传下去

挤压与拉伸是动画十二原则里最先被讲到的一条,也是最容易被理解成「把图缩一下」的一条。真正接进骨骼系统之后会发现,它带来的麻烦几乎全部来自一件事:非均匀缩放在层级里不能像旋转和平移那样安全地传递下去。

这篇先把「体积守恒」那点数学讲清楚,再说这个层级陷阱具体长什么样、为什么标准的矩阵连乘会产生错误的形状,最后是缩放中心、时间安排和高速情况下的替代方案。

二维里的「体积守恒」是面积守恒

挤压拉伸之所以看起来有质感,是因为观众默认物体的体积不变——压扁了就该变宽,拉长了就该变细。二维里对应的是面积。

做法很简单:一个轴缩放 s,另一个轴就缩放 1/s,面积 s · (1/s) = 1 保持不变。

def squash(s):
    return (s, 1.0 / s)        # 沿主轴 s,垂直轴自动补偿

但有两件事不会跟着守恒,实际做的时候都会撞上。

第一是周长。半径 r 的圆压成半轴 rsr/s 的椭圆,面积不变,周长却变长了。如果角色是描边风格、而描边宽度跟着一起缩放,那么压扁的瞬间线条粗细会明显变化,看起来像换了一支笔。矢量渲染里要把描边设成不随变换缩放;位图则要么把描边烘进图里接受这个变化,要么单独画一层不缩放的轮廓。

第二是极端值下的观感。严格的 1/ss = 0.4 时另一轴要放大到 2.5 倍,人眼会觉得「太夸张、不像同一个物体」。实践中的做法是给补偿加一个指数:

sy = 1.0 / (s ** alpha)        # alpha 取 0.6 ~ 0.8

alpha = 1 是严格面积守恒,取小一点会让极端姿态更收敛。这不是「不严谨」,而是承认观众判断的是可信度而不是面积。

缩放中心错了,球会陷进地板

在写任何层级代码之前,先有一个更基础的坑:缩放绕哪个点做。

一个球落地压扁,观众期待的是它的底部贴着地面不动、顶部往下压。如果绕重心缩放,压扁 s = 0.7 的那一帧,球的底边会往上抬 0.15 · 高度——看起来是球在接触地面的瞬间浮起来了一点,然后又落下去。这个瑕疵不大,但它恰好出现在最吸引注意力的接触帧上。

正确做法是把缩放的锚点放在接触点上,也就是形变时应当保持不动的那个位置:

def scale_about(p, anchor, sx, sy):
    return (anchor.x + (p.x - anchor.x) * sx,
            anchor.y + (p.y - anchor.y) * sy)

这条推广开来是:锚点应该选在语义上不动的那个点。球落地是底部,角色抬手举重物是肩,被拉伸的绳子是固定端。选错了不会报错,只会让运动多出一点说不清楚的漂移。

真正的陷阱:缩放不能穿过旋转传下去

接下来是这篇的核心。骨骼系统里每个节点的世界变换是父节点变换乘以自己的局部变换,局部变换通常写成平移、旋转、缩放三者的乘积。这套做法对平移和旋转完全没问题,对非均匀缩放则不然。

把父节点的缩放矩阵 S 和子节点的旋转矩阵 R 相乘看一眼就清楚了。取 S = diag(sx, sy)R 是转角 θ 的旋转:

S · R = | sx·cosθ   -sx·sinθ |
        | sy·sinθ    sy·cosθ |

sx ≠ sy 时,这个矩阵的两个列向量不再正交。它不是「旋转加缩放」,而是含有切变。也就是说,一个在被压扁的父节点下方、并且自身有旋转的子节点,得到的不是被压扁的形状,而是被斜切的形状。

症状很好认:角色整体做压扁时,凡是相对身体转了角度的部件——抬起的手臂、侧向的脚、歪着的头——会变成平行四边形,像被推倒了一样。而没有旋转的部件(θ = 0S · R = S)完全正常。「只有转了角度的部件变形不对」就是这个问题的签名。

这里要说清楚一点:切变本身不是矩阵实现的 bug,那个乘法是对的,它忠实地表达了「先在父空间里压扁,再在子空间里旋转」这个操作。问题在于这不是动画师想要的语义。动画师想的是「整个角色被压扁,各部件保持自己的形状」。

三种修法,以及该选哪个

第一种,让缩放不参与继承。父节点向下传递位置和旋转,但不传递缩放;每个节点的缩放只作用在自己身上。多数 2D 骨骼工具都有这个开关,通常叫「不继承缩放」之类的名字。代价是父节点的压扁不会自动带动子节点,需要在每个要变形的节点上单独给值——但换来的是形状永远正确。

第二种,把缩放留到最后。层级里只传平移和旋转,全部计算完得到每个顶点的世界坐标之后,再统一施加一次世界空间的压扁。这样整个角色像一张图一样被压,语义上最接近动画师的直觉,实现也简单。适合整体性的挤压——落地、受击、蓄力。缺点是无法表达「只压这条腿」。

world = parent_trs_without_scale @ local_trs_without_scale
p = world @ vertex
p = scale_about(p, contact_point, sx, sy)   # 最后一步,世界空间

第三种,分解后重建。每层乘完之后把矩阵分解回旋转和缩放,丢掉切变分量。这条路看起来最通用,实际上最不推荐:极分解在每帧都做既费又不稳定,而且丢掉切变意味着结果不再等于任何一个明确的操作,出问题时很难解释「现在这个形状是怎么来的」。

实际项目里的选择基本是:整体性形变用第二种,需要局部控制时用第一种,两者可以共存——大部分节点不继承缩放,落地压扁走世界空间那一层。

让速度自己驱动挤压

逐帧手 K 挤压量成本很高,而且主体动作一改就要重做。大部分情况下可以让它由速度算出来:物体动得越快,沿运动方向拉得越长。

speed = length(velocity)
s = 1.0 + clamp(speed * gain, 0.0, max_stretch)   # 沿运动方向拉伸
axis = normalize(velocity)                        # 拉伸的方向

s 和垂直方向的 1/s^alpha 组合成一个沿 axis 的缩放,就得到了自动的拉伸。落地时速度骤降为零、同时接触地面,用一个负的偏移量把 s 压到 1 以下就是挤压。整个效果只需要两个可调参数。

但这里有个必踩的坑:速度接近零时,方向是无意义的。物体在最高点、或者在两次运动之间停顿的那一瞬,速度向量的方向会因为浮点噪声疯狂乱转,而拉伸轴跟着乱转——即便此时 s 已经接近 1、形变量很小,边缘的抖动仍然看得出来。

修法不是给方向加平滑那么简单,因为方向是个角度,平滑它同样要处理绕圈。更稳妥的做法是设一个速度阈值,低于它就冻结上一次的有效方向

if speed > MIN_SPEED:
    last_axis = normalize(velocity)
axis = last_axis          # 慢速时沿用上一个有效方向

方向冻住不影响观感,因为这时候形变量本来就趋近于零,用哪个轴都看不出来。这条对所有「由向量方向驱动的效果」都成立——拖影、速度线、朝向对齐,全都要在模长过小时保留上一次的方向,而不是去归一化一个接近零的向量。

帧数比曲线更重要

数学之外,挤压拉伸的效果高度依赖它占多少帧。

典型的落地序列是:预备(下蹲蓄力)→ 接触 → 压扁 → 回弹拉伸 → 稳定。其中压扁那一帧通常只有一帧。在 24 fps、动画排在「二拍」上(即每个画面占两帧)的常见做法里,压扁往往只占一个画面。

压扁保持得太久是新手最常见的问题,观感是角色变成了橡胶。判断方法很直接:把压扁帧单独抽出来看,它应该像一张不该被看清的过渡画面;如果你能舒服地看清它,它就停太久了。

回弹的拉伸幅度一般小于压扁幅度,大约压扁量的一半到三分之二,并且要用一条起始快、结束慢的缓动回到常态。反过来(拉伸比压扁还夸张)会让物体显得没有重量。

速度足够快时,用拖影替代

还有一种情况是挤压拉伸解决不了的:物体运动太快。

判据可以量化——如果物体每帧的位移超过它自身在运动方向上的尺寸,相邻两帧的图像在空间上就不再重叠,观众看到的不是运动而是一串离散的位置,也就是频闪。这时候把物体沿运动方向拉长(传统动画里的「拖影」)可以人为制造重叠,恢复连续感。

need_smear = speed_px_per_frame > object_size_along_motion

拖影的长度大致取一帧的位移量,也就是让这一帧的形状刚好接上一帧的位置。再长会显得像残影而不是运动。这本质上是在用形变替代运动模糊——胶片时代的动画师是靠观察真实摄影得出这个做法的,而它成立的原因和现代渲染里做运动模糊完全一样。

顺带一提,一旦上了拖影,前面那个面积守恒就该放弃了。拖影的目的是欺骗时间维度上的感知,不是表现物体被压缩,这时候严格守恒反而会让形状显得太细。

可以自动检查的几条

这套东西大部分靠眼睛判断,但有三件事可以写成检查项,在绑定阶段就拦下来。

第一,正交性检查。遍历骨架,对每个节点的世界矩阵取两个列向量做点积。理想情况下应该接近 0;显著非零就说明这个节点吃到了切变,也就是它在某个非均匀缩放的父节点下面还转了角度。这个检查能在绑定完成时就把前面那个陷阱找出来,不用等美术反馈「手臂看起来怪怪的」。

第二,面积漂移检查。播放一整段动画,逐帧算角色包围形状的面积,画成曲线。挤压拉伸引起的起伏是预期内的,但曲线不应该有持续的单向漂移——那通常意味着某处的缩放被重复施加了,比如既参与了继承又在最后一步做了一次。

第三,锚点位移检查。对每个声明了接触点的形变,检查那个点在形变前后的位置差。它应当严格为零。不为零就是缩放中心配错了,也就是前面那个球陷进地板的问题。

发表回复

向下探索