把 3Blue1Brown 的 manim 引擎塞进浏览器
一个零依赖的本地数学可视化工具:八个模块、三十四个预设,所有参数都是滑块。
项目地址:github.com/wyfirstname/manim-ui
在 B 站或 YouTube 上看 3Blue1Brown 的视频,最抓人的往往不是结论,而是图形随着参数变化的过程:朱利亚集合在 c 慢慢移过复平面时一点点碎掉,中心极限定理的直方图从一坨方的慢慢长成钟形。
问题是,这种"过程感"在视频里是被固化的。看视频的人只会记住那个形状,记不住"参数往哪边拖会发生什么"。
所以我把 manim 的渲染引擎搬进了浏览器,参数全部做成滑块。
整个应用就三栏:左边选模块和预设,中间是画布,右边拖参数。
纯前端加一个零依赖的 Python 静态服务器,双击 启动.bat 就能用,不联网、不上传任何数据,中英双语一键切换。
一、先说清楚它不是什么
很多人第一次听到"浏览器里的 manim",会以为是把 .py 场景文件编译成视频再播放。
不是。 这里没有 Python 版的 manim,没有视频编解码,更没有把 Python 翻译成 JS 的桥。它做的是另一件事:
把 manim 的 GPU 着色器原样拿来,在浏览器的 WebGPU 上跑起来,再用 JavaScript 重写"接线"的部分。
manim 在 Python 侧主要是"准备数据":算几何、拼 uniform、决定画什么。真正把像素涂出来的活,是 22 个 WGSL 着色器干的。着色器是跨平台的------shaders/ 目录里的 22 个 .wgsl 文件,本项目一个字节都没有改。
嗯,一个字节都没改,这件事本身是有点好处的:上游改了 bug,我这边同步一下文件就完事。
二、八个模块,分别能玩什么
分形实验室(6 个预设) ------ 曼德博集合、朱利亚集合、兔形、仙人掌、曼德博放大、牛顿迭代法。
曼德博集合只是一个铺满画布的矩形,每个像素在着色器里自己迭代,所以放大到任意倍数都不糊。滚轮以光标为锚点,你可以对着边界上某个细节一直放大,它不会跑掉。朱利亚集合相反,形状由参数 c 决定,拖"实部 / 虚部"两个滑块,整幅图实时变形。牛顿迭代法解 z³ − 1 = 0,三个根各有一个吸引域,交界处有自相似的花纹。
函数绘图器(5 个预设) ------ 正弦与余弦、二次函数、黎曼和·定积分、曲线下的面积、自定义函数。
黎曼和把积分拆成一排矩形,拖"矩形数"看它逼近。这里用上了模板缓冲:填充是真正按路径绕数画的,不需要对图形做三角剖分,任意自相交路径都对。
向量场流线(4 个预设) ------ 涡旋、单吸引子、偶极子、双涡。
箭头表示方向,流线是沿场积分出来的曲线,两者的颜色都由速率编码------越快的区域越偏黄。这个模块参数最多,右栏有向量场、显示、视图三组,间距、箭头长度、箭头密度、流线密度、流线长度都在里面:
向量场流线,参数最多的一张界面。
三维几何(4 个预设) ------ 球面、环面、莫比乌斯带、正二十面体。
鼠标拖拽是轨道旋转,滚轮推近拉远。形状、材质、显示、视图四组参数,反光、高光、阴影三档一起调,金属感、塑料感随手就来:
三维几何,右侧四组参数里"材质"是最好玩的一组。
随机过程(3 个预设) ------ 大数定律、中心极限定理、随机游走。
这个模块的几何是我自己写的,不是上游的。大数定律抛硬币,经验均值收敛到 p,绿色带是 ±σ/√n 的收敛区间;中心极限定理把 n 个均匀随机变量之和标准化后画成直方图,叠加标准正态密度;随机游走里灰圈的半径是 √n·步长,画一会儿你就会发现个体基本都贴着圈跑。
傅里叶级数(3 个预设) ------ 方波、锯齿波、三角波。
把"有棱角"的周期函数写成无穷多项正弦波之和,再用有限的 N 项去逼近。这个模块最想讲清楚的是一件事:级数收敛的快慢不取决于"波多复杂",而取决于函数本身光不光滑。方波有跳变,系数只能按 1/k 衰减,N 再大过冲也压不平;三角波是连续折线,系数按 1/k² 衰减,N=2 就已经很像了。点播放,看着谐波一项一项加进来、波形一层层长成目标。
线性变换(4 个预设) ------ 旋转、剪切、缩放、奇异矩阵。
一个 2×2 矩阵到底对平面做了什么?把网格和单位正方形一起按矩阵搬一遍,答案就摆在眼前:色块的面积就是 |det M|,行列式为负时色块换成红色(平面被翻了个面),奇异矩阵(det = 0)把整个平面拍扁成一条直线------这就是"没有逆矩阵"的几何含义。点播放,矩阵从单位阵"长"到目标,正方形被逐步推倒、拉长、压扁。
概率分布(4 个预设) ------ 正态、二项、泊松、指数。
核心叙事只有一句话:曲线下的面积 = 概率。淡蓝是整条分布下方的面积(恒为 1),黄色是区间 a, b 的那块------这一块就是 P(a < X < b)。
三、看一个具体例子
八个模块里,概率分布最能体现这套界面的设计思路,拿它当例子。
正态 N(0,1):黄色阴影是 −σ, σ,面积恰好是 68.27%;顶部的条是概率标尺,高亮段长度 = 区间概率。
这张图里有三个设计取舍值得说:
第一,画布上不写字,用条说话。 顶部那根概率条是定长粗线加上按概率填充的粗线,把"概率"这个抽象量直接量化成一段可比的长度。不用读者去读数字,眼睛一瞄就知道边界的哪边伸得更远。
第二,均值是算出来的,不是画死的。 正态用 μ 和 σ,二项用 np 和 √(np(1−p)),泊松用 λ 和 √λ,指数用 1/λ 和 1/λ------每换一个分布,EX 和 √Var 都按各自的解析式重算。
第三,区间会自动整化。 二项、泊松是离散的,拖到 a = 7.4 会自动变成 8;区间跑到视野外时干脆不发高亮趟,省一次绘制。
点播放,区间上限扫过整个分布,阴影从零长到盖满曲线------这就是累积分布函数长出来的过程。
二项和泊松还能叠加一条正态近似曲线,n 一大就贴合;指数分布的默认阴影是 0, 1/λ,恰好是 1 − 1/e ≈ 63.2%,这是无记忆性的产物。
四、界面的通用操作
左栏挑模块和预设,右栏拖滑块。鼠标操作在所有模块里一致:
- 拖拽 ------ 平移视野(三维模块里是转动物体)
- 滚轮 ------ 以光标为锚点缩放
- 双击 ------ 复位
五个通用按钮:
- 播放 ------ 逐帧动画,每个模块都有各自的语义:分形缓慢放大、黎曼和逐渐加细、直方图长成钟形、游走一步步走、阴影长满整条曲线
- 导出 PNG ------ 离屏渲染加像素读回(不是截图,是真正重新渲染一遍)
- 录制动画 ------ 实时录成 WebM 视频,30 fps,上限 2 分钟,自动补写时长元数据
- 导出 GIF ------ 整段循环动画离线编成 GIF,60 帧、宽度上限 600 px
- 重置 ------ 恢复当前预设的初始参数
右上角一键切换中英,整个界面(含所有参数名和提示)都会跟着换:
切到英文,连参数名和提示文案一起换,不用重启。
五、五个值得单独讲的实现细节
填充:用模板缓冲数绕数。 要填充一个复杂路径,最笨的办法是做三角剖分。manim 走了另一条路------stencil-then-cover,用模板缓冲累计"绕数",三趟搞定:第一趟把填充三角形只写进模板缓冲(正面 +1、背面 −1,正背面就是三角形投影到屏幕后的有向面积的正负),于是每个像素上留下的恰好是路径绕它的绕数,不需要任何剖分,自相交也对;第二趟只在模板等于 0 的地方沿路径画一圈填充色,填充边缘的抗锯齿就靠这一圈;第三趟在模板非 0 处把同样的三角形再画一遍真正上色,同时把模板清零留给同一帧后面的绘制。
代价是每一条向量管线都必须声明 depth24plus-stencil8,并且每一个渲染通道都必须提供深度/模板附件,否则直接报错。
流线:归一化之后的 RK4。 直觉上会写成 p += F(p) · h,但点源、点涡这类场在奇点附近 |F| 趋于无穷,一步就飞出屏幕;在远处 |F| 又很小,稠成一团。所以先把场归一化再积分,让 dir = F / |F|。归一化之后不管场在局部多大多小,一步总是走同样的弧长------于是"流线长度"这个滑块调的是步数而不是步长,拉长流线时形状不会跟着变,只是延伸得更远。颜色用对数尺度,原因一样:线性映射会把奇点附近的整片区域压成最亮的黄色,什么都分不出来。
随机过程:种子化加前缀性质。 如果用 Math.random(),做出来的东西不好看也不好用。所以自己实现了 mulberry32,种子进参数面板,有三个好处:可复现(同一颗种子永远给出同一张图,拖线宽、改视野都不会重新抽样变样)、独立流(每条曲线用"种子 + 各自的盐"派生)、前缀性质(前 k 步只依赖前 k 个随机数)。
第三条最有用。因为前缀性质,播放动画时逐帧增大"已揭示长度",图形是连续生长 的,而不是每帧重新抽样乱跳------大数定律的曲线从左边一帧帧长出来,就是这个机制。中心极限定理没有这种天然嵌套结构,所以显式缓存了一张 M × 24 的均匀数矩阵,不管 n 取几都用同一批数的前 n 列,n 增长时看到的是同一个分布在变形,不是换了批数据。
自定义函数:不用 eval 写个解析器。 "自定义函数"预设允许直接写 a*sin(b*x+c)。最省事的做法是 new Function,但表达式是用户输入的------直接交给 JS 引擎等于把整个页面的控制权交出去,fetch(...)、location=... 都写得进去。所以自己写了词法分析加递归下降,只认白名单里的常量、变量和函数,其余名字一律报错,全程没有字符串拼接出来的代码。顺便说,log 以 10 为底、ln 是自然对数(跟着 Desmos 走),隐式乘法、√ 前缀、x²、全角符号都支持。
GIF 编码:从零写一个。 导出 GIF 不是"截图存成 .gif"。WebM 有浏览器现成的编码器,GIF 没有------规范里只定义了文件格式,没给编码方法。所以从头写:先把每帧 RGB 量化到 ≤256 色的调色板(中位切分),再用 Floyd--Steinberg 抖动把两色之间的误差扩散出去,最后 LZW 压缩成 GIF89a 数据流。整条链路零依赖,大约 400 行。
六、踩过的坑
uniform 布局必须和 WGSL 编译器完全一致。 一开始我担心着色器里的 vec3f 会按 12 字节紧凑排布,就在 JS 侧手动插了 _pad 填充。结果是所有字段整体错位,画面全黑。正解是:一个手动填充都不要生成,让 WGSL 按它自己的规则排布,JS 侧用同一套规则精确复现。
Array.isArray 对 Float32Array 返回 false。 我用它来判断"这是不是一组值",于是整个数组被当成单个值写进 uniform,得到 NaN,相机矩阵失效,矩形塌缩成一个点。改用 typeof v.length === 'number'。
离屏渲染的深度附件必须和目标纹理同尺寸。 为了画填充,所有向量管线都声明了 depth24plus-stencil8,这也意味着每个渲染通道都要带附件。更阴的是------偷懒复用画布那份深度纹理,尺寸对不上,WebGPU 会判定 attachment state 不兼容并直接丢弃整个 command buffer :不抛异常、不进 JS 控制台,表现就是画面全黑。只能靠 device.addEventListener('uncapturederror', ...) 把 GPU 侧的原话捞出来。
VMobject 记录必须保持曲线对齐,这个最隐蔽。 几何写成一个浮点记录数组,第 c 段曲线固定在记录 2c 上。这意味着每个子路径必须占偶数 条记录,一个有 n 个锚点的子路径占 2n−1 条(奇数),除最后一段外都要再补一条"分隔记录"。更隐蔽的是全对象记录总数必须是奇数 ,因为着色器按 records // 2 算曲线数,偶数会多算出一段越界的曲线。这个坑是加填充时才炸出来的------在那之前,函数绘图器的多子路径图形其实一直是错位的,只是描边看不出来而已。
正二十面体连成了"大二十面体"。 三维模块的面表我一开始是硬编码的,结果和顶点顺序对不上,连出来的东西轮廓凹陷、边长是 φ 倍、一半法线朝内。正解是程序化生成:三条边都是最短边的三角形恰好有 20 个,再统一绕向朝外。这个 bug 是靠离线渲染的预览图抓出来的。
七、怎么在一台跑不了无头 WebGPU 的机器上验证
开发机的无头模式起不来 WebGPU(就算加 --enable-unsafe-webgpu 也卡在 requestAdapter)。但可视化项目的 bug 又恰恰最容易出现在几何和数值上。我的办法是分两路。
第一路是 Node 离线测试。 写一个假的 WebGPU 设备,让代码以为自己在真机上跑,装配管线;关键是 queue.writeBuffer 把写入的 data 存下来,于是可以按层把真实几何 逐条解析出来,再用 CPU 独立复算一遍做比对。覆盖的东西包括模块描述符完整性、记录层不变量、填充绕数的 CPU 模拟、中英词条键对齐、三维几何不变量,以及随机过程的统计正确性------曲线是否逐点等于独立复算的前缀均值、游走的均方位移是否约等于 n·s²、同种子下 1600 步路径的前 400 步是否与 400 步路径逐点相同。
第二路是 CPU 离线渲染预览图。 用模块真实的几何数据在 CPU 上光栅化成 SVG,再用无头 Edge 截成 PNG。上面那些图就是这么来的------它们反映的是真实几何,不是手绘示意图。上面那个"大二十面体"就是这么暴露的。
顺便说一个统计上的教训:随机游走的均方位移估计量方差极大(单条游走的 sd 约 0.68),我一开始用一条游走去测,得到 0.66,差点当成 bug。独立复算确认无偏之后,改成 240 条平均加低方差统计量才稳定下来。别被单个样本吓到,先确认估计量本身无偏。
八、安装与使用
环境要求
- Edge 113+ 或 Chrome 113+(需要 WebGPU)
- Python 3.8+,标准库即可,不用装任何包
- 显卡随便,集显就够(本项目是在 Intel UHD 630 集显上开发的)
方式一:一键包(最省事)
不想装 Python、不想敲命令的话,直接下打包好的绿色版------内置便携 Python,解压双击 启动.bat 就能用:
链接:https://pan.baidu.com/s/1hhruk-omlsC7cVMUxi5Lsw?pwd=cjq1 | 提取码:
cjq1
方式二:从源码跑
git clone https://github.com/wyfirstname/manim-ui.git
cd manim-ui
python server/serve.py
Windows 上也可以直接双击 启动.bat。然后浏览器打开 http://127.0.0.1:7788/。
千万不要直接双击
web/index.html。 WebGPU 规范要求安全上下文(HTTPS 或http://localhost)。在file://下浏览器会直接把navigator.gpu藏掉,页面会报"这台机器不支持 WebGPU"------这是误导性的错误提示,不是你的显卡有问题。
九、最后
这个项目最让我满意的不是"用浏览器跑 manim"这件事本身,而是上游的着色器真的一行都没改。
某种程度上,这说明 manim 的渲染层设计得相当干净:几何怎么来、uniform 怎么排、管线怎么配------这些问题在 Python 和 JavaScript 里的答案是一样的。换掉语言,引擎照跑。
八个模块、三十四个预设、PNG / WebM / GIF 三种导出都已完成。接下来想把"预设场景"做成能保存分享的参数快照,欢迎提 Issue 点菜。
代码 MIT,着色器来自 3b1b/manim(同样是 MIT)。用着顺手的话点个 Star: