morphicons核心算法揭秘:2D Procrustes闭式解让图标旋转角度自动涌现,告别手动声明旋转组
【免费下载链接】morphiconsAny icon morphs into any other — universal morphing for stroke-based icons with spring physics. Zero dependencies, ~7 KB gzip.项目地址: https://gitcode.com/gh_mirrors/mo/morphicons
morphicons是一个图标变形(icon morphing)开源库:任意一个描边图标都能平滑地变形为另一个图标,带弹簧物理质感,零运行时依赖,核心仅 ~7 KB(gzip)。它的最大亮点是——旋转角度不需要你手动声明。箭头从右指变成下指,库会自动算出"这是一次 90° 旋转",然后让它优雅地转过去,而不是中间塌缩、剪剪切地"蠕动"过去。
这篇文章带你走一遍 src/core/ 里的核心算法,看看数学是怎么"涌现"出动画的。
痛点:为什么"旋转组"声明让人头疼 🤯
做图标变形的常见做法是直接对坐标插值。但两个形状如果只是旋转关系,逐点直线插值会让形状在飞行途中缩小、剪切——一个 90° 旋转的箭头,中间帧会变成一条"被压扁的斜杠"。
老派方案的补丁是给每对图标手工声明"旋转组":告诉引擎"A 的哪部分绕哪个中心转多少度"。问题来了:
- 每个图标对都要单独配置,图标一多就是维护噩梦
- 配置错了动画就"不对劲",但很难一眼看出来
morphicons 的思路反过来:不做配置,做计算。它把"两个形状之间的最优相似变换"当成一个有闭式解的数学问题来解。
全景:一条纯函数流水线
在动手解数学之前,morphicons 先把图标"驯化"成统一的数字表示,流程定义在 README.md 的 Architecture 一节:
icon A ─┐ ├→ normalize → resample → match → align → PLAN → interpolate(t) → d icon B ─┘ ↑ 可缓存、可序列化两个关键前置步骤:
1. 归一化(src/core/normalize.ts):所有 SVG 图元(直线、圆弧、圆、rect、polyline…)统一降级为三次贝塞尔段。
2. 弧长重采样(src/core/resample.ts):每条子路径按弧长均匀取 N = 64 个点。这一步直接消灭了"两条曲线阶数不同、点怎么对应"的结构性难题——对应关系就是按下标对。更妙的是拐角锚定:切线突变超过 22.5° 的点被固定为精确采样点,所以静止时形状是 100% 精确的,而飞行中源形状的尖角会自然变圆、目标形状的尖角会逐渐变锐,观感非常自然。
主角登场:2D Procrustes 闭式解
到了align阶段,对每一对对应的点云,morphicons 要回答:
找到一个旋转 θ、缩放 σ、平移的组合,使形状 A 尽量贴合形状 B。
这正是 2D Procrustes 问题。一般教科书会动用 SVD,但 2D 情形下它有闭式解——只需要一个atan2,实现在 src/core/plan.ts 的procrustes函数里:
S_xx = Σ aₓbₓ S_xy = Σ aₓbᵧ S_yx = Σ aᵧbₓ S_yy = Σ aᵧbᵧ (两云均以各自质心为中心) θ* = atan2(S_xy − S_yx, S_xx + S_yy) σ* = [cosθ*·(S_xx+S_yy) + sinθ*·(S_xy−S_yx)] / Σ‖aᵢ‖²一次遍历累加四个标量,一个三角函数——这就是"旋转角度自动涌现"的全部秘密:θ* 不是配置出来的,是解出来的。
残差:判断"这到底是不是旋转"的标尺
对齐之后还能剩多少误差?morphicons 计算归一化 RMS 残差res = √(Σ|σRa−b|² / Σ‖b‖²),它就是形状距离:
res ≈ 0:A 和 B 就是同一个形状,只是转了/缩了——纯旋转res较大:形状确实不同——老老实实做坐标变形
这样"旋转组"从一个需要人工声明的概念,变成了一个数学的涌现性质:arrow-right → arrow-down 的残差趋近 0、θ = 90°,系统自己就选择了纯旋转。
两个工程上的精修细节:
- 最小旋转平局裁决(src/core/plan.ts):像直线这种"反转后重合"的对称形状,两个遍历方向的残差都是 0,但推出的旋转角却不同。打分用
score = res + 0.05·|θ|/π——先看变形、再取短路径,避免图标莫名其妙多转 180°。 - 全局混合(src/core/plan.ts):先逐子路径 Procrustes,再对整幅图标做一次全局 Procrustes。若全局残差 < 0.005,说明整个图标是刚体关系,所有子路径共享同一个 (θ, σ)一起转——比如箭头的杆和箭头同向旋转,而不会各转各的。
极坐标插值:让旋转真的"转"过去
算出 (θ, σ) 还不够,得在对的空间里插值。如果直接对坐标 lerp,旋转问题照样塌缩。src/core/interpolate.ts 中的interpPolar把运动拆成"相似变换 + 残差"两部分:
P(t) = c(t) + σ*ᵗ · R(t·θ*) · [(1−t)·aᶜ + t·b̃]- 角度线性插值(θ* 已保证走短路径)
- 缩放对数线性插值(σᵗ = exp(t·lnσ),正实数上的测地线)
- 方括号里是"对齐系"内的残差混合
三个漂亮的性质:
- 若 B 就是 A 转过去的(res = 0),方括号恒为常数 →纯旋转,形状全程刚性
- 若无旋转,退化为干净的坐标变形
- 弹簧超调时 t > 1,公式天然外推:旋转和缩放会轻微过冲再回弹,免费的多汁感 🍹
还有一个容易忽略的修正——块体运输(src/core/plan.ts):全局刚体旋转时,若只对各子路径的质心做 lerp,偏心部件会走弦(弧线内侧),箭头的箭头会在中途向箭杆塌陷约 1px。让质心绕全局质心搭乘同一个相似变换,整个图标在飞行中保持刚性,且 θ = 0 时公式连续退化为普通 lerp,没有任何特判。
弹簧物理与"永不跳跃"的中断
进度 0→1 由一个阻尼谐振子驱动(src/core/spring.ts):
| 预设 | k | c | 阻尼比 ζ | 手感 |
|---|---|---|---|---|
| smooth | 170 | 26 | 1.00 | 临界阻尼,不超调 |
| snappy | 420 | 30 | 0.73 | 干脆,轻微过冲 |
| bouncy | 300 | 14 | 0.40 | 俏皮 |
半隐式欧拉、1/240 秒子步,约 25 行代码,不需要任何动画库。
中断处理是这套设计最舒服的地方:飞行中再次morphTo,引擎用当前中间形状作为新起点重建 plan(渲染缓冲已经是每子路径 64 个点,天然可作为变形源),并保留弹簧速度——狂点按钮,动画永远连续、永远不跳变。
性能与体积:小得离谱
plan(A, B)全流程(重采样 + 匹配 + Procrustes):N = 64 时亚毫秒,实测 0.01–0.06 ms- 每帧零数值内存分配:插值器直接写入 plan 预分配的
Float64Array,只有d字符串一帧一次分配 - 核心入口 gzip6.60 KB,CI 里用 size-limit 卡着 7 KB 的红线
上手体验
安装一行命令,React 用法就是换个 prop:
import { MorphIcon } from "morphicons/react"; <MorphIcon icon={open ? X : Menu} spring="snappy" />想看每对图标的 θ、σ、residual 读数?仓库自带 playground:
bun run play # 38 个真实图标,逐对展示检测到的相似变换想深入算法细节,推荐按这个顺序读:src/core/plan.ts(对应关系与 Procrustes 对齐)→ src/core/interpolate.ts(极坐标插值与块体运输)→ src/core/spring.ts(弹簧);数学原理的完整叙述见 README.md 的 "How the math works" 一节,设计决策见 docs/adr/。
一句话总结
别再手写旋转组了。把两个图标交给一个atan2,剩下的——角度、缩放、残差、刚体运输、弹簧过冲——全部由数学涌现。这大概就是"用计算替代配置"这句话最好的注脚。
【免费下载链接】morphiconsAny icon morphs into any other — universal morphing for stroke-based icons with spring physics. Zero dependencies, ~7 KB gzip.项目地址: https://gitcode.com/gh_mirrors/mo/morphicons
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考