形状系统解剖 · 基于 app.asar 解包逆向分析

25 个头像形状
是怎么被造出来的

它们不是 25 张手画的图,而是 25 段参数 + 4 张表 + 3 种轮廓算法。 而且有一条可以验证的硬约束:有一类形状天生不可能做出凹形。 下面每一节都有一个能动的演示。

25 个形状 3 种 silhouette 0 处例外

01

全景:一条五级流水线

点任意一级,看它负责什么。关键在第 5 级:同一个形状描述符会被分派给 三种轮廓算法之一,用哪一种决定了这个形状能不能有凹形。



  
一句话:形状 = 一条参数化路径 → 一个描述符(silhouette + seat)→ 交给 Zv() 按 kind 分派到 凸包 / 环间四边形 / 环缝合。

02

形状 = 两三个数字 + 4 个图元

每个形状在 Mv 表里就是一个吃掉 2~3 个数字的函数。比如六边形只要 「半径 / 边数 / 圆角」,云朵只要「5 个圆」,圆角方和宝石是同一个公式换一个指数。

// Mv 表:每个形状一个生成器,参数只有 2~3 个数字
hex:      () => eh(114, 6, 20, Math.PI/6)       // 正 n 边形 + 圆角
cloud:    () => th([[x-62,x+26,56], [x+62,x+26,54], [x,x+34,62],
                     [x-24,x-30,62], [x+38,x-26,54]])   // 5 个圆的并集
squircle: () => El(107, 107, 3.2)               // 超椭圆 |x/a|ⁿ+|y/b|ⁿ=1
gem:      () => El(112, 113, 1.5)               // 同一个公式,指数不同
egg:      () => av(98, 113, .22)
house:    () => lv(100, 110, 78, 29)
heart:    () => "M79.4511 11.5693C94.8767…"     // 唯一一个手写的
4 个基础图元: ev(Catmull-Rom 平滑闭合)· ac(圆角多边形,圆角可逐顶点给)· ar(极坐标函数采样)· th(圆并集的轮廓)。 eh / El / uv / lv 这些具名形状都是它们拼出来的。

实验室:拖滑块,看形状怎么变

左边选形状、拖参数,右边实时重绘 —— 全部按引擎的图元公式算。

对账:公式的输出 vs App 的真实输出

左列是直接 import 原 bundle、调用它自己的渲染函数输出后提取的真实轮廓, 右列是同一条公式的重绘。两者姿态不同 —— App 的输出带静止姿态的旋转 + 3D 投影 (所以方块看起来是斜的)—— 但形状是同一个。

03

三种 silhouette → 三种轮廓算法

描述符里的 silhouette.kind 只有三种,Zv() 按它分派。 这是整套设计里最关键的一个分支:

function Zv(silhouette, view) {
  if (silhouette.kind === "loft")  return [eS(silhouette.rings, view)];   // 环 → 缝合
  if (silhouette.kind === "solid") return [Qv(silhouette, view)];         // 点云 → 凸包
  // extrusion:逐环投影,环间连四边形
  const n = silhouette.rings.map(r => r.map(view.project));
  s.push([a[u], a[l], c[l], c[u]]);
}

25 个形状按算法分组

绿色徽标 = 凸形,橙色 = 有凹形。看第一组 —— 一个凹的都没有。

但注意方向。「凹 ⟹ 不能用 solid」是成立的;反过来「凸 ⟹ 只能用 solid」 不成立。数据里有 3 个形状(hex / square / house)实测是凸的,却仍然走 extrusion —— 因为作者是用「圆角多边形 + 厚度」那一族(Bn())来建它们的。 所以凸凹决定的是下界,不是唯一解。

04

核心洞察:凸包会「吃掉」凹形

浅色是 App 渲染的真实轮廓。切换开关,看把它交给 solid 的凸包算法之后会怎样。

这就是那个可验证的硬约束:solid 的轮廓算法是凸包 (pi(),Andrew 单调链),而凸包在数学上不可能产生凹形。 所以任何需要凹口 / 凹槽的形状,在设计上必须走 extrusion 或 loft —— 没有任何报错,只是形状静默地不对。

05

环是怎么造的

除了 solid 家族,另外两种都要先造出一叠环(rings),再把环投影。 两套公式完全不同 —— 拖滑块看环叠怎么变形。

extrusion 倒角挤出 · Nv()
// 轮廓朝自己中心缩,同时往 ±z 走
p = f/Ar*(Math.PI/2);            // Ar = 4
z     = ±(halfDepth - bevel + bevel*sin(p))
scale = 1 - bevel/u*(1 - cos(p))  // u = 最小半轴
loft 放样 · zv()
// 上下收成一个点,cos^rh 决定多「鼓」
d     = -PI/2 + PI*l/$l;      // $l = 16
scale = max(0, cos(d))^rh
z     = depth * sin(d)
// d = ±PI/2 时 scale = 0 → 收成点
两者的分工:extrusion 有明确厚度、边缘倒角收圆 (方、六角、星、齿轮、房子);loft 在赤道最胖、上下收成一个点, 像枕头 / 云 / 心。凹形状选哪个,取决于你想要「一块板」还是「一个包」。

06

球链家族:bean / tablet / capsule

这三个完全不生成路径 —— 它们用一串球当轮廓(silhouette.balls, samples 是空的),最后靠射线和圆求交得到并集边界。 所以它们也归 solid 家族,同样只能凸。

tablet / capsule 沿轴排一串球 · jl() · Rl = 33
function jl(name, axis) {
  for (f = 0; f < 33; f++) {
    h = lo + (hi-lo) * (f/32);
    push(axis === "x" ? [h,0,0,c] : [0,h,0,c]);
  }   // 半径 c = 短半轴
}

引擎用 33 个球;图中画 11 个便于看清「一串球」。主轴由 PCA 求出, 所以球链是沿形状自己的长轴走的。

bean 最大内切球 · Lv()
function Lv(name) {
  for (网格点 p of 包围盒) {
    if (!点在轮廓内(p)) continue;
    r = min(到轮廓每条边的距离);
    if (r > 步长) 收下(p, r);
  }
  // 按半径降序,贪心丢掉被大球包住的
}

对 App 真实轮廓跑内切球算法,网格步长 = 最大尺寸 / 40。

轮廓怎么从球算出来

// Qv():一圈方向,每个方向取所有球的最远交点 = 并集边界
for (const dir of DIRECTIONS) {
  let best = 0;
  for (const ball of balls) {
    d = dir·(ball.c - center);
    g = d*d - |ball.c - center|² + ball.r²;
    if (g <= 0) continue;            // 这根射线打不到这个球
    best = max(best, d + sqrt(g));   // 取最远交点
  }
  outline.push(center + dir * best);
}

07

眼睛落在形状表面的哪里:seat

轮廓做好了,眼睛还得「贴」上去。描述符里第三个字段 seat 就是干这个的。 这就是为什么换形状之后,眼睛不会飘在体外。

kind 字段 含义 谁在用

球链形状还额外带 puffAt, 让眼睛在球与球之间的凹陷处也能贴合。

08

速查表

25 个形状的完整分派。凸/凹与凸包损失两列是实测值 —— 对 App 自己渲染出来的轮廓跑凸包算出来的,不是抄的。

形状 引擎名 Mv 生成器 silhouette 轮廓算法 seat size 凸/凹 凸包损失

09

数据来源与自检

形状系统只是这个引擎的一半;另一半是动画(弹簧、clip 贝塞尔、状态机、眨眼)。 见 总览页。想自己加形状 → 形状工作台。
文中全部结论来自对 Grok Bot(Electron, macOS)app.asar 解包后的逆向分析; 设计与数据版权归原所有者,仅供学习研究。