☰
纯函数引擎设计:bloub如何一招冻结任意时刻的SVG头像
2026/10/11 0:51:46 网站建设 项目流程
  • 前端
  • 图形学

【免费下载链接】bloub

SVG recreation of the x.ai bot avatar. One shape morphing through 14 states, measured off the reference video frame by frame.

项目地址:https://gitcode.com/gh_mirrors/bl/bloub
点击查看免费下载

🕐bloub是一个用 Vue 3 + TypeScript 从零实现的 x.ai 机器人头像 SVG 重制项目:一个黑色剪影在 14 种状态间平滑变形,双眼独立变化。它最核心的设计只有一句话——engine.sample(t)是一个纯函数:时间 t 是参数,不是时钟。这一行约定,让"冻结任意时刻"、"回看历史帧"和"无 DOM 单元测试"全部水到渠成。本文用最少代码,带你看懂这套纯函数引擎是怎么搭起来的。

它要解决什么问题?

先看效果:下面的 14 个头像全部是同一套引擎在固定时刻采样得到的静帧,没有任何动画循环参与。

普通动画的做法是:浏览器里起一个requestAnimationFrame循环,每帧累加一个内部计时器,用Date.now()推进。这套方式有个致命伤——画面由"内部时钟"驱动,于是:

  • 想冻结某一帧?得停表、暂停循环,状态散落在各帧回调里;
  • 想跳到第 7.3 秒?只能快进播放;
  • 想写单元测试?必须 mockDate、mock DOM,或者干脆测不了。

bloub 的答案很干脆:把时间从引擎里彻底请出去。

纯函数是什么?一分钟建立直觉

纯函数指:相同的输入必然得到相同的输出,且调用不改变外部世界。

// bloub 的整个渲染入口,src/bot/engine.ts frame = engine.sample(1.2) // 1.2 = 状态开始后的第 1.2 秒

sample(t)满足三条纪律(见 engine.ts 的注释):

  1. 不读墙钟——没有时间参数进来,引擎自己绝不去问"现在几点了";
  2. 不读Date.now(),不 import Vue——src/bot/ 目录与框架完全解耦;
  3. 采样不产生副作用——sample绝不修改引擎内部状态,重读同一时刻永远得到同一张图。

换句话说,引擎内部那些字段(当前状态、过渡起点、变形起始时刻……)都不是"实时演化的时钟",而是一份带时间戳的事件记录:setState('orbit', 2.0)只写下一条"2.0 秒时切到 orbit"的日志。真正"算画面"这件事,只发生在sample(t)被调用、且 t 传进来的那一刻。

冻结任意时刻:暂停、跳转、倒看全免费

因为画面是 t 的函数,以下操作天然成立,无需任何额外机制:

  • 暂停= 不再调用sample,画面停在最后一帧;
  • 跳到任意时刻= 直接sample(任意 t);
  • 回看历史= 再次sample(过去的时间)——引擎不会"清理"旧状态,所以 0.7 秒的中间帧在 2.5 秒之后照样能原样重算出来;
  • 确定性= 两个独立引擎实例对同一时刻的采样结果完全一致,位级相同。

项目用测试把上述性质钉死了(engine.test.ts):

  • "两次读同一日期,结果必须一致"
  • "状态切换的淡入过程中依然可重放"——这是最容易踩的坑:若在淡入结束后清除旧状态,再读淡入期间的时刻就会丢帧
  • "读一个早于状态切换的时刻,不得越界外推"——ease-out 曲线对负数比例会 extrapolate,让剪影飞出 30 倍距离

外部输入怎么进来?——"带时间戳的 setter"

你可能会问:切换状态、换身体形状、鼠标指向眼睛,这些外部输入不也影响画面吗?

bloub 的答案是:所有外部输入必须通过"带日期的 setter"进入(setState(id, now)、setShape(radii, now)、setLook(look, now)),setter 只记录"什么时刻发生了什么",绝不立刻改变画面。sample(t)读到这条记录时,再按各自的缓动曲线(项目统一用从参考视频实测出的easeOutQuint)把过渡算出来。

这是纯函数纪律里最精细的一环:architecture 文档 专门记了一个真实教训——"在播放中清理过期状态,看起来是无害的优化,却会让引擎失去可重放性"。它已经真实翻过车,于是有了上面那条专门的测试。

实际收益:状态面板、缩略图与离线 GIF 导出

纯函数设计不是纸上谈兵,它在 App.vue 里有三个"白拿"的用法:

  1. 冻结状态板:URL 带上#planche,14 个头像并排定格。实现就是给每个 BloubBot.vue 组件传frozen-at属性——组件拿到它后只调一次engine.sample(frozenAt),根本不启动动画循环;
  2. 时间轴缩略图:TimelineTrack.vue 里每个事件块的小预览图同理,一个frozen-at搞定,不用为 14 个缩略图开 14 个播放器;
  3. GIF / MP4 导出:capture.ts 用一个"屏幕外引擎"逐帧rendAt(t)渲染整个循环,比实时播放快得多——因为渲染不依赖墙钟,帧与帧之间的间隔可以无限小。

这就是 README 里那句 "framework-free and clock-free" 的全部含金量:同一套sample(t),白天是页面上的动画,晚上是导出管线的离屏渲染器,测试里是纯数据函数。

这套设计的三条底线(可以直接抄进你的项目)

  • 时间永远是参数:任何模块内部出现Date.now()或performance.now()读时间,纯函数链就断了;
  • 事件带时间戳,采样不带副作用:setter 只追加"何时发生"的记录,sample只读不算账;
  • 旧状态不清理:清理历史是"看起来无害、实则毁掉可重放性"的典型优化,bloub 用一条专门测试把它挡在门外(docs/architecture.md)。

小结

bloub 把一个 SVG 头像做得像参考视频一样"逐帧可测量",靠的却不是逐帧录制,而是反过来——让engine.sample(t)成为时间的纯函数:暂停、跳转、回看、离线导出、无 DOM 测试,全部是同一个约定的免费赠品。如果你在做一个需要精确帧控制的动画项目,这套"时钟外置"的纯函数引擎设计,值得直接搬走。

  • 核心引擎:src/bot/engine.ts
  • 14 个状态的定义与实测时长:src/bot/states.ts
  • 组件封装与frozen-at用法:src/components/BloubBot.vue
  • 架构决策与踩坑记录:docs/architecture.md
  • 前端
  • 图形学

【免费下载链接】bloub

SVG recreation of the x.ai bot avatar. One shape morphing through 14 states, measured off the reference video frame by frame.

项目地址:https://gitcode.com/gh_mirrors/bl/bloub
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询