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 里,所以它的长度随眼神逐帧变化)。
本卡片与 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 眼睛描述符),宽度比实时算给你看。切换「平面贴眼」看看常见省事方案什么样:
眼睛等宽平移、滑出边缘也不消失——瞬间就假了。
转动头部,观察两只眼睛宽度比变化
07
眨眼与生命感
眨眼不是独立调度器,而是写在 clip 里的关键帧事件:clip 的 expression 轨道上
一个 v:"blink" 事件会被编译器展开成三段 lids 关键帧——睁开 →
0.26×0.45 秒处完全闭合 → 0.26 秒处重新睁开,缓动 [.42,0,.58,1]。
眼睑把眼睛纵向压扁成细 stadium(胶囊)。
除 clip 内嵌事件外,还有两个来源:命令式 blink() 调用(用户交互触发),
以及 through-blink 的表情切换(先闭眼再换表情睁开)。经逐行核对原 bundle:
不存在环境自动眨眼调度器——全 bundle 唯一的 Math.random
在网络重试退避里,与动画无关。「随机的外表」其实来自别处:idle 的 clip 洗牌(见下一节)。
右边是从提取数据 grok-recipes.json 直接画出的三条 idle clip 时间轴,
竖条 = 该 clip 内嵌的眨眼事件(Idle_D 里 2.07s 与 2.37s 那对就是一组双眨)。
上方是跑着真引擎的 bot;也可以点按钮手动触发一次 blink()。
当前 clip:—
真实数据:grok-recipes.json 中 Idle_A / Idle_B / Idle_D 的 expression 轨道; 橙色 = 双眨(间隔 <0.6s 的一对)
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 的发令枪—— 这正是它必须用贝塞尔、不能用(渐近的)弹簧的原因。
09
状态陈列馆:53 个状态一次看全
原引擎的状态注册表一共 53 项,按用途分五组。下面每一张卡都是 正在运行的原版引擎实例(grok-player + 提取数据),不是截图、不是录屏。
注意一个事实:53 个状态,底层只有 28 条 clip。状态不是 53 段手绘动画, 而是 clip 池 × 表情(face)× morph 装饰 的组合——reading / working / coding / running / computing / controlling 六个状态共用同一组 Working clip, 表现力全靠组合撑开。
点任意卡片可以戳它一下(poke)。带 next 的状态会真的自动转移:
waking 播 1.6 秒自己变回 idle;blocked 则要撑满 120 秒才进入 drowsy——
这张卡你等不到,但机制就在数据里。
53 个引擎实例同页运行;离屏与被过滤的卡片由引擎内置 IntersectionObserver 自动休眠,不耗 CPU。
10
横向对比:为什么选程序化 SVG
Grok Bot 的头像需要:随时响应视线的眼睛、53 个状态、任意尺寸渲染、离线可用。 在这四个约束下,素材型方案全部出局——剩下的就是代码。
| 程序化 SVG Grok Bot | Lottie | 视频 / GIF | WebGL shader | CSS 动画 | |
|---|---|---|---|---|---|
| 素材体积 | 0 字节(代码即素材) | json 几十~几百 KB | 数百 KB~MB 级 | shader 小但需运行环境 | 0 字节 |
| 实时交互(视线跟随 / 状态机) | 每帧重算,天然支持 | 播放固定时间轴 | 完全不可能 | 可以 | 有限(keyframes 固定) |
| 分辨率无关 | 矢量,任意缩放 | 矢量 | 像素,放大即糊 | 可做到 | 矢量 |
| 运行时代码量 | 一个引擎 JS 文件 | 需 lottie-web 运行时 | <img> 即可 | WebGL 上下文 + shader 编译 | 0 |
| 跨端可移植性 | 任何支持 SVG 的环境 | 各端需对应 player | 哪里都能播 | 依赖 GPU/WebGL | 仅 Web/浏览器 |
| 离线 / 无 GPU 可用 | 纯 CPU 矢量运算 | 可以 | 可以 | 无 GPU 即死 | 可以 |
我该选哪种?
你的角色需要实时交互吗?(视线跟随、状态机、对用户输入做反应)
能接受大体积素材、且内容一旦导成就不可逐帧程序化修改吗?
目标环境能保证 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 解包后的逆向分析;
动画设计与数据版权归原所有者,仅供学习研究。