基于对 Grok Bot(Electron, macOS)app.asar 解包的逆向分析

一个不用任何
动画库的角色
动画引擎

Grok Bot 的头像引擎:没有 Lottie json,没有 gif/webm/mp4, 没有 glsl/wgsl,没有一张图片——全部图形在运行时用代码算出来。 这个页面上你看到的每一个 demo,都是它讲的那项技术本身。

0 个动画库 0 字节素材 60fps 程序化生成

原版引擎移植(grok-player)· 原版动画数据 —— 动一下鼠标,它会看你

01

每帧都是算出来的

这套引擎在 JS 里维护一棵虚拟 SVG 节点树,每帧重新计算所有图形参数, 再由一个 DOM diff 写入器把变化落到真实 DOM:结构不变时只写 attribute,结构变了才 createElementNS 重建。

身体是 <path fill>;眼睛不是盖在身体上的白色形状,而是从身体上挖出来的洞——所以身体下面是 什么背景,眼睛就透出什么背景。挖洞有两条路:sphere 模式下直接把眼睛的地平线弧 布尔裁剪进身体 path;morph 状态下改用 SVG <mask>。

右侧属性面板实时显示当前帧的 path d 片段与各层 transform, 并用 MutationObserver 聚合每帧的真实 DOM 写入。打开「显示 DOM diff」 可以看到:idle 时每帧只有一次写入——身体 path[d] (眼睛的裁切弧就长在这条 path 里,所以它的长度随眼神逐帧变化)。

body path[d]—
mask 眼洞[d]—
head g[transform]—
zoom g[transform]—
帧内 DOM 写入—
mask 结构—

本卡片与 hero、03/05/07/08 节同为原版引擎移植(grok-player);属性面板用 MutationObserver 观察真实 SVG 子树的每帧写入。「逐帧步进」把引擎切到确定性模式(frozenAt),一次只推进 1/60 秒。

02

形状 morph:径向轮廓插值

怎么把圆形「变成」爱心?它的做法是向 192 个基准角度方向发射线 (再叠加轮廓顶点处的角度),求每条射线与形状轮廓的交点,得到一个半径数组 r(θ)。 morph 就是对两个形状的 r(θ) 逐角度线性插值, 插值权重由弹簧驱动——所以形变本身也带物理感。

原引擎的形状注册表有 25 种(cloud / square / sparkle / clover / heartChubby / star6 / teardrop / house / gem / egg / capsule…),但对外只暴露几个别名: blob / sphereWide / arch / spark / star / heart / hexagon(见提取数据 grok-recipes.json 的 shapes.aliases)。

右边拖 slider:圆形 → 六角星 → 爱心 → 水滴连续混合。打开「显示采样射线」可以看到 射线和交点;把采样数从 192 降到 8,看插值质量如何崩坏——采样密度就是这套方案的精度旋钮。

—

圆星爱心水滴

03

编排用贝塞尔:clip 关键帧

53 个状态的全部身体动画,底层只有 28 条手工关键帧 clip:每条 clip 分 position / rotation / squash / zoom / lids / expression 六条轨道, 相邻关键帧之间各带一条独立的三次贝塞尔缓动 ease:[x1,y1,x2,y2]——与 CSS cubic-bezier() 同算法(牛顿迭代解出 x(t),再取 y)。

和 CSS 不同的是:y 允许超出 [0,1]。比如 Idle_B 的缩放轨道就有 [.87,−.08,.30,1.02]——起点下潜、终点过冲,那点「肉感」回弹正来自超界。 右边上面拖动两个控制点试试;预设全部取自真实 clip 数据,扫掠点的高度就是 ease 输出。

为什么编排动画不用弹簧?clip 是有死线的时刻表:第 1.62 秒头必须正好转到 −8.66°;状态切换时引擎现合成的 bridge clip(0.35s,dx 缓动),它结束的那一帧 就是状态机调度下一条 clip 的发令枪。弹簧是渐近的,给不了这个承诺——它的用武之地在第 05 节。

右下是活的:mini-bot 跑着原版引擎,曲线实时画出它此刻正在播放的 clip 的所选轨道(菱形 = 真实关键帧,悬停看数值与缓动)。想逐帧 scrub、改关键帧, 去 动画编辑器。

—

曲线 = bot 当前真实播放 clip 的轨道;播放头与引擎逐帧同步(debugFrame)。 clip 播完会洗牌换下一条,曲线自动跟着换。

04

弹簧,不是定时器

引擎里没有任何 setTimeout 动画,也没有 CSS transition。但运动并不都来自弹簧—— 编排好的动作走上一节的贝塞尔 clip;弹簧只兜底「目标何时改变无法预知」的连续量: 装饰系统的 amount / swap / spin(下一节实测)和身体换形的交叉淡化权重 (setBody,ω=10)。它们共用一个手写的半隐式欧拉弹簧积分器, 以 120Hz 固定子步推进:

v += (-2ζω·v - ω²·(x - target))·dt
x += v·dt

只调两个参数——刚度 ω 和阻尼 ζ——就能得到从 Q 弹到稳重的全部质感。 而传统的 ease-out 曲线是时间的函数:中途改变目标,它只能生硬跳变;弹簧只要改 target,速度天然连续。

零第三方动画库:没有 framer-motion,没有 gsap。

05

弹簧用在哪:amount / swap / spin

功能状态(thinking / waiting / alerting…)= 身体缩小让位 + 程序化装饰图元。 装饰系统的全部手感来自三个并行弹簧,各回答一个问题:

  • amount(ω=14)装饰模式在不在?0→1 入场:身体缩小、眼睛退场、clip 减速;1→0 退出
  • swap(ω=11)装饰换人了没?kind 变化时归零重弹:旧装饰 ×(1−swap) 下场、 新装饰 ×swap 上场——交叉淡化,中间没有空态。它的静止值是 1,不是 0
  • spin(ω=14)进场转个身:每次激活掷硬币选 ±π 并累加到目标; 退出时同方向再补 π——进场半圈 + 退出半圈 = 正好一整圈,不留残角

三种切换各动不同的弹簧:idle→thinking 只有 amount 爬升(swap 已在 1); thinking→waiting 只有 swap 归零重弹(交叉淡化); thinking→idle 只有 amount 回落。点右边按钮,三条曲线是引擎里的 实时实测值(debugFrame),不是示意图。

竖线 = 一次状态切换;虚线 = 弹簧当前目标值。spin 曲线按 ±π 卷绕显示, 读数框里是未卷绕的原始值(π 的倍数)。

06

伪 3D:眼睛真的在球面上

头部姿态是一组完整的旋转(原引擎用四元数存 pitch/yaw/roll)。眼睛不是贴在脸上的贴图: 眼睛脊线上的点经罗德里格斯公式(含 sinc)映到单位球面、随头部做 3D 旋转,再正交投影到屏幕; 转到背面的部分沿地平线弧裁掉——眼睛真的会绕到球后面消失。

靠近边缘的眼睛被球面几何自然压缩:逆向时实测远侧眼宽 ≈ 近侧的 0.69 倍,这正是球面深度系数,不是手调的 hack。

右边拖拽球体旋转头部。球面侧跑的就是原引擎同一套数学 (grok-player 导出的 lb/ir/ST/xT + grok-recipes.json 的眼球基向量与 neutral 眼睛描述符),宽度比实时算给你看。切换「平面贴眼」看看常见省事方案什么样: 眼睛等宽平移、滑出边缘也不消失——瞬间就假了。

转动头部,观察两只眼睛宽度比变化

08

状态机与 recipe 洗牌

原引擎的状态注册表有 53 个状态(idle / listening / thinking / blocked / finished / waking…)。每个状态 = 若干条手工关键帧 recipe(position / rotation / squash / lids / expression 轨道,每段独立三次贝塞尔缓动)。

idle 播放时种子化洗牌抽取下一条 clip、随机相位起播——所以待机永不重复。 next + holdSeconds 支持自动转移(如 blocked 持续 120 秒 → drowsy); 尺寸小于 48px 时自动切换精简动画。

功能状态 = 身体缩小让位 + 程序化装饰图元。右边从 53 个状态里选了 7 个代表: idle / listening / thinking / waiting / alerting / working / finished——点哪个播哪个, 下方显示状态机当前真实播放的 clip。

这些动画是原版引擎的忠实移植(grok-player)+ 从本机 app 提取的原版动画数据, 不再是简化重实现。idle 的 clip 池、洗牌与相位全部由移植引擎内的种子化 PRNG 驱动。

正在播放:—

切换解剖 —— 点上方任意按钮,看四条机制如何在 1.6 秒内接力:

bridge 与表情 morph 是有死线的贝塞尔,按真实时长填槽;amount / spin 是 debugFrame 实测的弹簧值。bridge 结束的那一帧就是状态机起播下一条 clip 的发令枪—— 这正是它必须用贝塞尔、不能用(渐近的)弹簧的原因。

10

横向对比:为什么选程序化 SVG

Grok Bot 的头像需要:随时响应视线的眼睛、53 个状态、任意尺寸渲染、离线可用。 在这四个约束下,素材型方案全部出局——剩下的就是代码。

程序化 SVG
Grok Bot
Lottie视频 / GIFWebGL shaderCSS 动画
素材体积0 字节(代码即素材)json 几十~几百 KB数百 KB~MB 级shader 小但需运行环境0 字节
实时交互(视线跟随 / 状态机)每帧重算,天然支持播放固定时间轴完全不可能可以有限(keyframes 固定)
分辨率无关矢量,任意缩放矢量像素,放大即糊可做到矢量
运行时代码量一个引擎 JS 文件需 lottie-web 运行时<img> 即可WebGL 上下文 + shader 编译0
跨端可移植性任何支持 SVG 的环境各端需对应 player哪里都能播依赖 GPU/WebGL仅 Web/浏览器
离线 / 无 GPU 可用纯 CPU 矢量运算可以可以无 GPU 即死可以

我该选哪种?

你的角色需要实时交互吗?(视线跟随、状态机、对用户输入做反应)

11

看不见的工程细节

动画引擎(以及本页的移植封装)的高级感,一半来自这些没人注意的角落:

  • requestAnimationFrame 驱动,60fps 上限节流
  • document.hidden 时暂停整个循环
  • IntersectionObserver 离屏停帧——看不见的 bot 不耗 CPU
  • 动画静止后自动休眠,事件到来再唤醒
  • prefers-reduced-motion 时完全静态
  • DOM diff 写入器:结构不变只写 attribute,变了才重建节点
  • 尺寸 < 48px 自动切精简动画

本页面自身就是这套哲学的实践:0 第三方运行时依赖、0 构建, 纯 HTML + CSS + 原生 ES modules;hero 与 01/03/05/07/08 节的 bot 运行原版引擎的移植 (js/vendor/grok-player.js)与从本机 app 提取的动画数据 (js/vendor/grok-recipes.json),其余 demo 实时计算, 全部遵守离屏休眠与 reduced-motion。
文中全部结论来自对 Grok Bot(Electron, macOS)app.asar 解包后的逆向分析; 动画设计与数据版权归原所有者,仅供学习研究。