一个教艺术家画画的库,里面藏了一台 JS 转 Shader 的编译器

发布于 · 3,457 字 · 约 9 分钟#Github 解读#DevOps原文链接
一个教艺术家画画的库,里面藏了一台 JS 转 Shader 的编译器 封面图
  • Strands 将 JavaScript 函数编译为 WebGL/WebGPU 的 Shader,底层使用 DAG、CFG 等编译原理
  • p5.js 的 noise()并非真正的 Perlin 噪声,而是值噪声加余弦插值,文档存在误导
  • 无障碍模块实现在画布描述、颜色命名、网格输出,体现对教育用户的重视
  • 友好错误系统利用 AST 分析提供参数校验和错误提示,可关闭以提升性能

大家好,我是若风。

2013 年,Lauren Lee McCarthy 把 Processing 搬到了浏览器上,取名 p5.js。它的定位很明确,教艺术家、设计师、学生写代码,用最少的语法画出最好看的东西。十三年过去了,23860 个 Star,3800 个 Fork,上周还在往 main 分支合代码。

如果你只看这些数字,大概会觉得这是个「老牌教育工具」,界面友好,代码简单,仅此而已。

但前阵子我翻 p5.js 2.0 的源码,发现了一个叫 Strands 的模块。这个模块做的事情让我愣了一下,它把普通的 JavaScript 函数,编译成 WebGL 和 WebGPU 的 Shader。

一个画图库里藏着一台编译器。而且这台编译器用了控制流图、有向无环图、中间表示,这些编译原理课上的东西。

这篇文章就来拆解 p5.js 2.0,重点看三件事。Strands 这台编译器到底怎么工作,noise() 这个被用烂的函数为什么其实不是 Perlin 噪声,以及这个号称最「无障碍」的创意编程库,到底在无障碍上做了多少真东西。

先搞清楚 p5.js 到底是什么

一句话,p5.js 是 Processing 的 JavaScript 版。Processing 是 2001 年 Casey Reas 和 Ben Fry 在 MIT 做的创意编程环境,核心思想是把画布当成草稿纸,用 setup() 和 draw() 两个函数就能开始创作。

p5.js 继承了这套心智模型。你写三行代码就能在网页上画一个跟随鼠标的圆。

function setup() {
  createCanvas(400, 400);
}

function draw() {
  background(220);
  circle(mouseX, mouseY, 80);
}

这段代码背后的运行机制值得说一句。p5.js 有两种模式,全局模式(global mode)和实例模式(instance mode)。

全局模式下,所有 p5 的方法都挂在 window 上,你直接写 circle() 就能用,看起来像魔法。实例模式下,你需要显式创建一个 p5 对象。

// 实例模式
const sketch = (p) => {
  p.setup = () => { p.createCanvas(400, 400); };
  p.draw = () => { p.background(220); p.circle(p.mouseX, p.mouseY, 80); };
};
new p5(sketch);

看 src/core/main.js 的构造函数,全局模式的核心是一个静态指针 p5.instance。当你不传 sketch 闭包直接实例化时,p5 会扫描全局作用域,把 window 上的 setup、draw 等函数挂到实例上,然后把所有 API 也注入 window。这种设计对新手极度友好,但代价是你只能跑一个 sketch,多个 sketch 会互相污染全局空间。

这个代价 p5.js 团队认了。教育场景下,简单比严谨重要。

Strands,把 JavaScript 编译成 Shader

这是 p5.js 2.0 里最让我意外的部分。

写 WebGL 的人都知道,Shader 是用 GLSL 写的,一种类 C 的语言。你不能用 JavaScript 写 Shader,这是浏览器的硬限制。WebGPU 时代换成 WGSL,但问题一样,你还是得学一门新语言。

p5.js 的思路是,能不能让用户继续写 JavaScript,然后在背后偷偷编译成 Shader。

Strands 就是干这个的。

我读了一遍 src/strands/ 目录,整个编译管线大概分成这几步。

第一步,解析。Strands 用 acorn 这个 JavaScript 解析器,把用户写的函数体解析成 AST。src/strands/strands_transpiler.js 负责这件事。

第二步,生成中间表示。这是最硬核的部分。Strands 不是简单地把 JS 翻译成 GLSL,它先构建了一套自己的 IR(中间表示)。具体来说有两个数据结构,一个是 DAG(有向无环图),由 src/strands/ir_dag.js 构建,用来表示数据依赖关系。另一个是 CFG(控制流图),由 src/strands/ir_cfg.js 构建,用 createBasicBlock、pushBlock、popBlock 来管理代码块。

如果你上过编译原理课,这两样东西你应该认识。DAG 管数据流,CFG 管控制流,几乎所有现代编译器都会用到。

第三步,代码生成。src/strands/strands_codegen.js 把 IR 翻译成具体的 Shader 代码。关键在于它支持多个后端,WebGL 用 GLSL,WebGPU 用 WGSL。源码里你能看到 src/webgl/shaders/functions/noise3DGLSL.glsl 和 src/webgpu/shaders/functions/noise3DWGSL.js 这两套并行的 shader 函数实现。

这套设计有几个工程细节值得点赞。

变量名管理。Shader 里用户变量名和编译器生成的内部变量名需要一一映射,Strands 在 src/strands/strands_names.js 里维护了一个双向映射表,createStrandsShaderNameMap 和 createStrandsShaderNameState 配合工作,避免命名冲突。

声明收集。用户的 JS 函数里可能有些声明需要放到 shader 的不同位置,src/strands/p5.strands.js 里维护了三组 Set,vertexDeclarations、fragmentDeclarations、computeDeclarations,分别收集顶点着色器、片段着色器、计算着色器的声明,最后拼到对应位置。

用户在 JavaScript 里写一个函数,Strands 在背后做了解析、类型推断、控制流分析、数据流分析、多后端代码生成,最后吐出能在 GPU 上跑的 shader。

整个管线从上到下可以分成五层,用户只接触最顶层,剩下四层全是黑盒。

Strands 编译器管线
Strands 编译器管线

坦白讲,我在一个创意编程库里看到完整的编译器后端,是没想到的。

noise() 说了十三年的谎

讲完硬核的,讲一个有意思的。

p5.js 有个 noise() 函数,文档里写得很清楚,这是 Perlin 噪声,Ken Perlin 在 1980 年代为电影《电子世界争霸战》(Tron)发明的那个算法。

文档原文是这么说的。

Ken Perlin invented noise() while animating the original Tron film in the 1980s.

但 issue #7430 有人较真了,标题就叫 noise() is not Perlin noise。

这位开发者指出了两个硬伤。

第一,Perlin 噪声在整数网格位置上定义梯度,噪声值是梯度和偏移向量的点积。但 p5.js 的实现直接在每个整数位置赋一个随机值,然后做插值。

第二,真正的 Perlin 噪声在所有整数位置上的值都是零。因为偏移向量为零时点积必然为零。但 p5.js 的 noise(0, 0, 0) 根本不返回零。

我去读了 src/math/noise.js 的源码,确认了这个说法。

p5.js 的实现里,perlin 数组存的是随机值(不是梯度),插值用的是 scaled_cosine,也就是 0.5 * (1.0 - Math.cos(i * Math.PI))。这是余弦插值,不是 Perlin 使用的缓和曲线(quintic fade 6t^5 - 15t^4 + 10t^3)。源码顶部的注释链路也很有意思。

// Adapting from PApplet.java
// which was adapted from toxi
// which was adapted from the german demo group farbrausch

这个实现一路从 Processing 的 Java 版改编过来,Processing 又参考了 toxi 的 Java 库,toxi 又参考了德国 demo 团队 farbrausch 的代码。传了这么多手,算法早就偏离了 Perlin 的原始定义。

严格来说,p5.js 的 noise() 是一种值噪声(value noise),用余弦曲线做平滑插值。它确实能产生「有机感」的随机数,连续输入产生连续输出,但它在数学上不是 Perlin 噪声。

这个 issue 到现在还开着。提交 issue 的人说得很客气,他认为对 p5.js 的目标用户来说这不构成实际问题,但文档应该说明这个偏差。

我同意这个判断。noise() 对做创意编程的人来说足够好用,视觉效果上和真正的 Perlin 噪声差别不大。但作为一个标榜教学用途的库,文档写「Ken Perlin 发明的算法」却实现了一个不是 Perlin 的算法,这件事本身值得被知道。

noiseSeed() 的实现也值得一提。它用的是线性同余生成器(LCG),参数来自 Numerical Recipes 那本书推荐的值,模数 m = 4294967296(2 的 32 次方),乘数 a = 1664525,增量 c = 1013904223。这是一套经过验证的伪随机数参数,周期够长,分布够均匀。拿到种子后,它预填充一个 4096 大小的 perlin 数组,后续的 noise() 调用就在这个数组上做插值。

工程上没什么毛病,就是名字起错了。

无障碍不是说说而已

p5.js 把自己定位成 accessible creative coding library,accessible 排在第一位。我在很多项目里见过把「无障碍」写进 README 但代码里一点影子都没有的情况。

p5.js 不一样。

src/accessibility/ 目录里有实打实的代码。describe.js 提供了 describe() 函数,让开发者用文字描述画布内容,这段文字会被注入 DOM,屏幕阅读器可以读出来。它支持两种模式,LABEL 模式会在画布旁边显示描述文字,FALLBACK 模式只对屏幕阅读器可见。

更细的是 color_namer.js。这个模块做了一件很体贴的事,它把颜色转换成名字。当 p5.js 为画布生成无障碍描述时,它不是冷冰冰地输出 RGB 值,而是把颜色归类成 gray、red 这些人类能理解的词。源码里有一组 colorExceptions,专门处理那些 HSB 值落在边界上的颜色,避免归类错误。

还有 gridOutput.js 和 textOutput.js,前者把画布内容按空间网格组织成表格输出,后者输出纯文本摘要。一个画了圆和方块的 sketch,经过这些处理后,视障用户用屏幕阅读器就能「读」出画面里有什么。

这套无障碍能力不是 p5.js 2.0 才有的,1.x 版本就在做。但 2.0 把它放到了更显眼的位置,src/accessibility/ 现在是独立的一级模块。

友好错误系统,给新手的安全网

src/friendly_errors/ 是另一个能体现 p5.js 教育定位的模块。

大多数 JS 库的错误处理方式是,参数传错了抛异常,堆栈追踪甩你脸上。p5.js 觉得这对新手不友好,于是搞了一套 Friendly Error System(FES)。

param_validator.js 会校验你传给 p5 函数的参数类型。它甚至用了 Zod 这个 schema 验证库(import * as z from 'zod/mini'),为每个 p5 函数缓存参数 schema,运行时做类型检查。你给 circle() 传了个字符串当半径,它不会直接崩,而是告诉你参数类型不对,顺便给出正确用法的示例。

sketch_verifier.js 更有意思。它用 acorn 解析你的整个 sketch 脚本,检查你是不是在 setup() 和 draw() 外面误用了 p5 函数。这个场景对新手特别常见,把 background() 写在文件顶层,然后不知道为什么不生效。sketch_verifier.js 会扫描你的代码,发现这种错误后友好地提示你,这些函数应该放在 setup() 或 draw() 里面。

源码里有一个 ignoreFunction 列表,列出了所有合法的事件回调函数名,setup、draw、preload、mousePressed、keyPressed 等等,避免误报。

代价当然是性能。运行时做参数校验和 AST 解析都有开销。p5.js 提供了 p5.disableFriendlyErrors = true 让你关掉它,生产环境或者追求帧率的时候可以用。

这个项目的真实状况

聊了这么多亮点,得说说另一面。

贡献者集中度是个问题。我查了 p5.js 的贡献者数据,排第一的 @davepagurek 有 1683 次提交,第二名是创始人 @lmccart 的 946 次,第三名 @limzykenneth 892 次。到第十名就掉到 393 次。真正的核心维护者大概就三四个人。

p5.js 采用轮值领导制(rotating leadership model),2020 年开始,每隔一段时间换 Lead。这种制度的好处是避免单一维护者倦倦,坏处是知识传递需要时间,每次交接都有磨合期。

issue 区有一些长期未解决的 feature request。#5670 要求给核心 API 加虚线(dashed lines)支持,29 条评论,到现在没合。#4630 要求一等公民级别的 SVG 导入导出支持,也是 29 条评论,悬而未决。#6795 要求加载资源时显示视觉加载指示器,34 条评论,等了很多版本。

这些不算致命问题,但能看出来,一个以志愿者为主的项目,在面对「锦上添花」的功能需求时,响应速度是有限的。核心路径(渲染、事件、基础图形)维护得很好,边缘需求排期很慢。

还有个细节值得提。p5.js 的 AI 政策在开源项目里算比较明确的。README 里直接写了。

This project does not accept fully AI-generated contributions. AI tools may be used assistively only.

不接受纯 AI 生成的代码贡献,AI 只能作为辅助工具。而且专门有个 AGENTS.md 文件来规范 AI Agent 在这个仓库里的行为。在一个 2026 年的 GitHub 项目里,这种态度值得尊重。

构建系统的换代

最后说一个工程层面的变化。

就在我写这篇文章的前一天,p5.js 把构建系统从 webpack 迁移到了 rolldown + vite。main 分支最新的几个 commit 是这个。

Migrate build to rolldown and vite
Run automated lint fix
Run automated formatting
Merge pull request #9062 from limzykenneth/rolldown

package.json 里的 build 脚本从 webpack 换成了 rolldown -c,dev 用 vite preview/,测试框架从原来的方案换成了 vitest,linter 换成了 oxlint。

这套全是 Rust 写的工具链(rolldown 是 Rust 的 bundler,oxlint 是 Rust 的 linter)。一个十三年的项目,构建工具换了不知道多少轮,每次换都是为了让开发体验和产物体积跟上前端生态的演进。这份维护心态在老项目里不多见。

写在最后

拆完 p5.js 2.0,我最大的感受是,不要用一个项目的「定位标签」去预判它的技术深度。

p5.js 对外宣传的是 accessible、beginner-friendly、creative coding。这些标签会让人觉得它是一个「简单」的项目。但你往里看,Strands 是一台完整的编译器,FES 是一套运行时类型系统加 AST 分析器,无障碍模块是真刀真枪的颜色命名和空间网格输出。

这背后有个方法论我觉得值得带走,叫坡道设计。

好的教学工具会造一条缓坡,让用户在不知不觉中走得很远。p5.js 的用户第一行代码写 circle(50, 50, 80),这是一个函数调用。三个月后他在写 shader 逻辑,用的还是函数调用,只是背后 Strands 帮他编译成了 GLSL。他不需要知道 DAG、CFG、IR 这些概念,就能拿到 GPU 算力的好处。

同样的思路,FES 把编译原理里的 AST 分析变成了「你是不是把这个函数放错位置了」的友好提示。参数校验把类型系统变成了「circle 的第三个参数应该是数字哦」。

复杂度没有消失,它被搬到了底层,留给用户的是一条平缓的坡道。这种设计思路,比单纯堆功能或者标榜易用,要难得多。

如果你做的是面向非专业用户的开发工具,p5.js 这条坡道的修法值得好好看看。

评论互动

© 2026 王若风的技术博客 · Powered by Astro