项目详情

Bloub

用测量学复刻 x.ai bot 头像:一个黑色形状 morph 出 14 个状态,零动画库

jeremy-prt/bloub MIT TypeScriptVue 3 + ViteSVG 动画

更新于 2026-09-16 · GitHub 数据采集同日

← 返回汇总
项目速览

一句话定位

把参考视频逐帧测量,用纯 SVG + 时间纯函数复刻 x.ai bot 头像的每一个像素级行为

一个填充黑色形状在 14 个状态间 morph,两只白色眼睛独立 morph,纯色背景。没有 GSAP、没有 framer-motion、没有弹簧引擎——所有常量都是从 10 fps 切帧的参考视频里测量出来的。适合研究「测量驱动的前端动画」「无时钟引擎设计」「SVG morph 数学」的开发者。

项目速览

核心数据

1.5k
Stars(3 天)
187
Forks
14
Avatar 状态
64
轮廓采样角

2026-08-15 建仓 · 08-17 发布 v0.1.1 后停更 · 30 commits · 仓库体积约 1.2 MB · 火于独立创作者社区的「逆向测量」叙事

测量反直觉

五个「你以为」全是错的

直觉以为视频实测
眼睛倾斜 //倾斜 \\,偏离竖直约 26°
身体是 squircle(超椭圆)正圆,径向偏差 < 0.7%
过渡用弹簧(spring)指数 ease-out,身体从不 overshoot;全项目唯一弹簧是通知贴纸 +14% 弹出
彗星态:点划过屏幕点不动,尾巴绕着它转
静止时头像上下浮动不浮动(中心稳定 ±0.003),生命感全靠视线漂移 + 眨眼

彩蛋:favicon.svg 不是近似画——圆 + 两个眼矩阵 = engine.sample(1) 对 idle 态的字节级输出,右眼比左眼窄(0.64 vs 0.87)是球面深度压缩,不是 bug。

核心理念

常量是测量值,不是设计值

取整一个常量,就毁掉一分相似度——而相似度是这个项目唯一的成功标准

🔥 测量管线

从视频到 profiles.ts

reference.mp4 本地输入,不入库 ffmpeg fps=10 切帧 h_0001.png… 轮廓 · 子像素 ray casting silhouette 半径 眼睛 · capsule fitting PCA 拟合位置/尺寸 颜色/线宽 · 直接采样 逐帧取样 tools/extract-profiles.py numpy + pillow,生成不手改 → src/bot/profiles.ts(仅 egg / hexagon / triangle 需提取, 其余 = 测量圆或 skins.ts 解析构造)
绿框 = 测量路径 · 黄框 = 本地输入 / 生成脚本(reference.mp4 与 frames/ 不入库,完整复现需自备视频)
🔥 架构

无框架、无时钟的核心层

接入层 · SRC/UI + SRC/COMPONENTS(Vue 3) BloubBot.vue 组件 + composables + 显示设置 v-model: block/state frozenAt 精确帧 导出 SVG/PNG/GIF/MP4 i18n 法/英/中 核心层 · SRC/BOT(框架无关 + 时钟无关,禁止 Date.now() / Vue import) engine.ts — sample(t) 纯函数 states.ts — 状态声明 ArcSpec profiles.ts — 视频提取轮廓 skins.ts — 解析构造皮肤 face.ts — 眼/表情/眨眼 eyefit.ts — 补偿表一次解算 cycles.ts — montage 编排 shape.ts — radiusAtAngle 核心不变式:sample() 不持有随真实时间变化的内部状态,也不 mutate——暂停/恢复/跳到任意时刻/跑测试,得到同一帧
实线绿框 = 核心层(纯函数,可 DOM-less 测试)· 虚线灰框 = UI 层(Vue)· 跨层共享的 Vue 代码放 src/ui/,绝不进 src/bot/
核心机制

engine.sample(t):时间纯函数

  • 同输入 → 同帧:暂停、恢复、跳转任意日期、测试,图像完全一致——这是冻结状态板和 DOM-less 测试的全部前提
  • montage 回放字节级稳定:v0.1.1 的 fade 修复验证跑了 1921 帧 + 15 态状态板 + 任意日期重读,输出与修复前逐字节一致
  • sample() 禁止 mutate:曾在形状 morph 里「顺手」清除过期旧状态,导致 fade 结束前回读旧日期找不到数据——现已加专属回归测试
  • fade 中途切状态:从冻结的合成 pose 起混合,而不是旧状态的完整 pose——把 26–43px 跳变压回 10–14px 量级
// 只在 fade 进行中冻结合成 pose
// (每帧都冻结会让 alert 的「!」
//  半路停摆)
setState(s) {
  if (fadeInProgress)
    origin = freeze(currentPose)
  blend(origin, s, easeOut)
}

// 时间纯函数(伪码)
engine.sample(t) → Frame // 无副作用
🔥 核心机制

64 角采样,morph = 半径线性插值

形状 A:蛋 r(θᵢ) → [100.4, 112.9, …] 形状 B:三角 r(θᵢ) → [84.7, 66.2, …] 对应索引逐项插值 r′(θᵢ) = rₐ(θᵢ) + (r_b(θᵢ) − rₐ(θᵢ)) · t easeOutQuint 只作用于 t morph 帧(t = 0.5) 绿点 = 64 个采样点一一对应
核心机制

眼睛是 mask 上的洞,球面上的点

  • 不是叠白椭圆:眼睛是 <mask> 挖的洞,滑向轮廓边缘时自动裁剪,零裁剪代码
  • 球面模型:远眼宽 = 近眼 0.69 倍(面积 0.663),正是球面上该点的深度因子——压缩、倾斜、转到背面全由切空间正交投影自然涌现,拟合残差约 1px(190px 球)
  • body 背后垫不透明 paper 底:环和爆散粒子的后半段画在 body 之下被遮挡;没有这层底,环会从眼睛洞里漏出来
  • 贴边元素都要走 radiusAtAngle:眼睛和通知贴纸按局部半径 pro-rata 缩放,否则非圆形状上直接出界
黑色 body(mask 挖出两只 \\ 倾斜的眼) 虚线 = mask 洞位置:右眼更小 = 球面深度压缩 0.69
最深的坑

eyefit:为什么不能在渲染循环里解算

失败路径 × 7 个版本

每版都在渲染循环里实时解算眼睛位置,每版都产生可见运动伪影——因为解算器读的输入(静止视线漂移、指针、表情 morph 中间态、最近边缘、最紧的眼)全都在以 60fps 移动:

  • active-set 切换:最近边缘跳变 → 26 单位回退
  • 非光滑目标函数:min 是 C0 不是 C1
  • chattering:逐帧反馈回路高频抖动

结论:缺陷不在任何一版的几何里,而在「逐帧求解」这个动作本身。

正解:查表(pose space deformation)
  • eyefit.ts 在 import 时一次性解算,产出一个 (shape × base-body 态 × 表情) 补偿表——即角色绑定的 PSD 思路(Lewis/Cordner/Fong, SIGGRAPH 2000)
  • 引擎只在 morph 边界查表(shapePrev/shape 等成对端点值),再用该 morph 自己的 easeOutQuint 插值——绝不读插值中间值
  • 不再跑在动画里 → 无连续性要求 → 可以迭代收敛、对所有变量取最坏情况(含静止视线漂移的解析边界)
  • 补偿是纯平移(等距变换):间距、大小、倾斜逐像素保持,脸在窄体上「坐低一点」——正是人手会做的手势
失败复盘

六种「看起来可行」的修法,全部实测翻车

修法为什么翻车(全部有测量)
每只眼各自避让两眼朝向不同、退缩量不同 → 眼距散开,扭曲的脸比裁剪更难读
沿半径向中心退走长对角线换少量竖直空间 → 双眼被拖到中间并成一团,283/680 组合
整脸缩放比例全保但眼睛明显变小,换表情时还会把缩放动画化
取最差眼Math.min 是离散选择,morph 中途约束眼切换 → 推力方向翻转,三角上 11.6 单位
每形状一条、对表情取最坏胶囊上 neutre 眼位高需下移、effrayé 眼位低需上移,单个平移无法同时满足,实测仍 4 组合溢出
放不下就放弃wide 态 87 单位眼 + 三角/泪滴必溢出;规则改为「追求最不坏」,不许留真实溢出不改

680 个 组合逐一扫过,且不只扫组合还扫时间——曾因只测单时刻,让 capsule+frightened 靠静止漂移「漂」过了边缘。skins.test.ts 锁死全部行为:测「来回抖」而非速度,区分永久性抖动(静止漂移/形状 morph:不许多于正圆)与换表情这类合法移动。补偿瞄准的是原始 profile 的贴边余量,不是安全间隙——正圆上外眼本来就擦边(体积感的来源),追求 clearance 会让眼睛恰好相切、看得出来。圆上两轮廓重合 → 补偿恒为 (0,0),favicon 一像素不动。

核心机制

假 3D 与真时长:montage 不缩放时间

工程文化

测量驱动的修复文化

先复现,再修

CHANGELOG 每条 fix 都「先复现并测量,测量在修复 commit 里」:montage 接缝处眼睛轨迹 53.4 单位跳变 → 修后 0.6;MP4 编码器失败泄漏 → 后续导出启动即死;localStorage 被禁抛 SecurityError 白屏 → 持久化改为可选。

测试即重放

纯函数引擎使测试无 DOM:默认周期 1921 帧、15 态状态板、任意日期重读逐字节回归;eyefit 表由 skins.test.ts 全量锁定(扫组合 × 扫时间)。vue-tsc 是唯一门禁,无 ESLint/Prettier。

细节即证据

favicon = sample(1) 字节级;竖直「!」是锥形杆(上下比 1.76),斜「!」是胶囊杆且点是泪滴;「#etat= 重复动画死循环」源于 hashchange 被误读为导航、lookup 取首次出现——一点击即触发。

产品面

不只是复刻:一个可玩的头像工坊

  • Customise:8 种身体形状 × 12 色 × 16 种静止表情,跨访问保持
  • Animations:小时间线编辑器——排列状态、设保持时长、保存结果
  • 导出一切:头像 → SVG/PNG/动画 GIF,整条时间线 → GIF/MP4;静态格式零依赖,视频编码器首次用到才加载
  • 两个深链 URL:#planche 14 态并排冻结;#etat=orbit&stop 直达单态暂停
  • 三语界面:法/英/中,手写翻译层
// 当 Vue 组件用
<BloubBot v-model:block="block"
          v-model:state="state"
          v-model:playing="playing" />

<BloubBot state="orbit" :size="120"
          :frozen-at="1.2" />  // 渲染单帧,无动画循环

// props: size/shape/color/expression/
//  paper/frozenAt/cycle/follow/gaze
// models: block/state/playing/elapsed
风险提示

需要清醒的地方

在线体验

Playground:径向轮廓 Morph 实验台

本地复现 bloub 的核心机制——选两个形状拖 morph 进度条,看 64 角采样点半径逐项插值;再拖眼睛滑向轮廓边缘,亲眼看 mask 洞被「切开」——正是 v0.1.1 修掉的那个 bug,开 eyefit 补偿开关看表驱动平移如何救场。零网络、纯几何。

打开 Morph 实验台

上一个
AIDE ML
下一个
Sirchmunk
1 / 17