形状系统解剖 · 基于 app.asar 解包逆向分析
25 个头像形状
是怎么被造出来的
它们不是 25 张手画的图,而是 25 段参数 + 4 张表 + 3 种轮廓算法。 而且有一条可以验证的硬约束:有一类形状天生不可能做出凹形。 下面每一节都有一个能动的演示。
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…" // 唯一一个手写的
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 个形状按算法分组
绿色徽标 = 凸形,橙色 = 有凹形。看第一组 —— 一个凹的都没有。
hex / square /
house)实测是凸的,却仍然走 extrusion ——
因为作者是用「圆角多边形 + 厚度」那一族(Bn())来建它们的。
所以凸凹决定的是下界,不是唯一解。
04
核心洞察:凸包会「吃掉」凹形
浅色是 App 渲染的真实轮廓。切换开关,看把它交给 solid 的凸包算法之后会怎样。
solid 的轮廓算法是凸包
(pi(),Andrew 单调链),而凸包在数学上不可能产生凹形。
所以任何需要凹口 / 凹槽的形状,在设计上必须走 extrusion 或
loft —— 没有任何报错,只是形状静默地不对。
05
环是怎么造的
除了 solid 家族,另外两种都要先造出一叠环(rings),再把环投影。
两套公式完全不同 —— 拖滑块看环叠怎么变形。
Nv()// 轮廓朝自己中心缩,同时往 ±z 走 p = f/Ar*(Math.PI/2); // Ar = 4 z = ±(halfDepth - bevel + bevel*sin(p)) scale = 1 - bevel/u*(1 - cos(p)) // u = 最小半轴
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 家族,同样只能凸。
jl() · Rl = 33function 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 求出, 所以球链是沿形状自己的长轴走的。
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
数据来源与自检
- 25 条真实轮廓:直接 import 原 bundle 的渲染函数(
jE/a6) 渲染后提取的路径数据,因此带着引擎静止姿态的旋转与 3D 投影。 - 凸/凹与凸包损失:对该轮廓跑 Andrew 单调链凸包,比较面积 —— 页面上的数字是实时算的。
- 几何层:
js/lib-shapes.js是原 bundle 的逐函数移植 (gn/ev/ar/ac/eh/th/El/pi/Nv/zv)。 - 对账:02 节的径向剖面残差也是页面实时算的 —— 用旋转/缩放无关的度量, 确认每条公式各自贴着自己的目标形状。
形状系统只是这个引擎的一半;另一半是动画(弹簧、clip 贝塞尔、状态机、眨眼)。
见 总览页。想自己加形状 → 形状工作台。
文中全部结论来自对 Grok Bot(Electron, macOS)app.asar 解包后的逆向分析;
设计与数据版权归原所有者,仅供学习研究。