如果你打算用 JavaScript 做点图形、动画、交互式的小玩意,p5.js 是一个绕不开的库。它最大的价值不是给你一套炫酷 API,而是把“写代码画图”这件事的思考方式整个换掉了——从操作 DOM、拼页面,变成面对一块画布、一支虚拟画笔、一个不断刷新的循环,这在很多场景下(教学、原型验证、数据可视化、艺术装置)都比传统前端开发更顺手。这篇内容是针对 p5.js 基础部分的完整拆解,适合刚接触创意编程、以前写过一点 JavaScript 但对画布绘制没什么概念的人,也适合已经会用其他绘图库、想换到 p5.js 找找感觉的开发者。我会把环境搭建、核心循环、绘图机制、交互处理、随机与噪声这些基础点一条条讲透,顺便夹带一些项目里踩过的坑。
1. p5.js 到底解决了什么问题,以及它为什么适合创意编程
1.1 从 Processing 到 p5.js:把编程变成“手绘”
p5.js 是 Processing 的 JavaScript 移植版,它的设计初衷是让没有计算机图形学背景的人也能快速做出可视化、动效和交互作品。Processing 本身是面向艺术家和设计师的教学语言,p5.js 保留了这套“跑起来就有窗口、有坐标、有鼠标事件”的直觉体验,底层却完全跑在浏览器 Canvas 上。
打个比方:传统 JavaScript 开发像搭积木,你关心的是页面结构、数据流转、组件状态;而 p5.js 更像站在一张白纸前面,你不断告诉系统“在这个坐标画一个圆、在那个坐标拉一条线”,系统自动帮你把每一帧画面推到屏幕上。这个思维差异很重要。如果你的目标是“让人直接看到输出”,p5.js 几乎是最短路径。
它解决了前端领域一个常见尴尬:想做个简单原型,比如鼠标拖拽画线条,用纯原生 Canvas 写几十行代码还要手动管理重绘和事件,而 p5.js 内置了绘制函数、事件系统和动画帧循环,几行就搞定。对新手友好是它最核心的定位。
1.2 与传统 JavaScript 开发方式的差异
用 p5.js 写代码,你会明显感觉到它和 Vue、React 这类框架的思考方式不同。p5.js 没有组件树,没有状态管理,没有虚拟 DOM,它采用“全局函数 + 轮询绘制”的模式。
具体来说,你写的setup()、draw()、mousePressed()都是全局函数,p5.js 内部会在合适的时机调用它们。这里隐藏着一个对新手不那么友好的点:你写在全局的变量之间是互相可见的,没有模块隔离,代码一长就容易靠变量名硬撑。所以写 p5.js 项目时,我自己习惯用“工厂函数 + 工具模块”的方式组织代码,既不破坏 p5.js 的全局函数机制,又能让项目可维护。
还有一点:p5.js 的绘图模式属于“立即模式”,每次调用background()、rect()、ellipse()这些函数都会立刻更新画布像素,而不是像 Canvas 的标准 API 那样手动保存路径再fill()填充。这也意味着你必须在draw()里重复描述画面内容,不能“画一笔就完事”。很多第一次接触的人会困惑为什么图形不保留,就是这个机制造成的,后面会详细说。
2. 搭建本地开发环境:五分钟从零到第一个窗口
2.1 最省事的方案:一个 HTML 文件加远程库
p5.js 的上手成本极低。新建一个 HTML 文件,引入 p5.js 的 CDN 链接,然后写两个函数,保存后用浏览器打开就能跑。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>p5.js 快速开始</title> <script src="https://cdn.jsdelivr.net/npm/p5@1.9.0/lib/p5.min.js"></script> </head> <body> <script> function setup() { createCanvas(800, 400); background(200); } function draw() { // 每一帧都会执行 circle(mouseX, mouseY, 30); } </script> </body> </html>这段代码打开后,你会看到一个浅灰色窗口,鼠标在窗口上移动时,会不断留下圆圈。这个效果只用了几行代码,但里面已经涉及了画布创建、坐标读取、循环绘制三个核心概念。实际操作中,CDN 链接可能因为网络原因加载不稳定,如果你不想依赖外部网络,可以下载 p5.min.js 文件放到本地。
2.2 本地库文件与开发服务器
把 p5.min.js 下载到项目目录后,引用改成本地路径:
<script src="p5.min.js"></script>这时候要注意一个问题:直接用浏览器双击打开 HTML 文件时,由于本地文件安全策略,某些资源(比如以后你加载图片、json 数据)可能被浏览器拦截。虽然纯绘图不受影响,但一旦涉及loadImage()、loadJSON(),就会报跨域错误。所以我建议从一开始就养成本地开发服务器的习惯。
最简单的做法是:在项目目录下运行下面这条命令(需要本机有 Python):
python -m http.server 8000然后访问http://localhost:8000就能正常加载所有资源。如果你本机有 Node.js,也可以用npx serve或者live-server,都是一样的原理。这一步别跳过,等到项目里要加载图片时你就知道多省事了。
2.3 官方编辑器与浏览器控制台的配合
p5.js 官方有一个网页编辑器,在浏览器里可以直接写代码并预览,很适合零基础入门。不过我个人不建议长期依赖它,原因有两点:一是编辑器内置的代码提示和项目文件管理很一般,二是你最终要回到本地工程化的环境里,过早习惯云端编辑反而增加迁移成本。
更好的组合是:本地开发 + 浏览器自带的开发者工具。平时用console.log()打印变量,实测下来这是排查 p5.js 问题最高效的方式。p5.js 的报错信息还算友好,比如函数名写错会直接提示未定义,坐标值超出画布范围并不报错,但这种“不报错”的问题通常要靠观察画面变化来发现,这时候控制台里的日志就是救命稻草。
3. setup 与 draw 的生死循环:p5.js 的核心机制
3.1 setup() 只跑一次,draw() 永不停歇
p5.js 程序的生命周期非常像拍动画片:setup()是开机前的布置,只执行一次,用来创建画布、初始化变量、加载资源;draw()是摄影机,每秒钟默认播放 60 次,每次播放都重新执行一遍里面的代码。
理解了这个循环,你就能理解 p5.js 里所有画面刷新的底层逻辑。举个例子,如果你在draw()里写rect(100, 100, 50, 50),这个矩形不是被“放”在画布上,而是每一帧被重新画一遍。如果中间有代码把它覆盖了,它就消失。所以想让画面“保留”,要么每一帧重新画所有内容,要么用全局变量保存状态,然后每帧基于状态绘制。
这种机制的代价是容易写出性能不好的程序——比如每帧都创建大量临时对象,或者每帧都调用耗时的图像处理函数。但优点是逻辑简单直观,尤其适合表达“动态系统”,比如粒子运动、物理模拟、数据变化驱动视觉,这正是 p5.js 被大量用于数据可视化和艺术创作的原因。
3.2 frameRate 与动画节奏控制
默认draw()每秒跑 60 次(即 60 帧每秒),你可以用frameRate()调节。比如做棋盘、像素画这类不需要高频刷新的作品,可以降到frameRate(10),明显减少 CPU 占用。
一个常见的坑:frameRate()不等于显示器刷新率。实际显示帧率受设备性能影响,可能低于你设定的值。如果你想做精确的时间控制,不要依赖把frameCount++当计数,要用deltaTime或者millis()来计算时间差。
let lastTime = 0; function draw() { let delta = millis() - lastTime; lastTime = millis(); let posX = posX + 100 * delta / 1000; // 每秒移动 100 像素 circle(posX, 200, 20); }这样写的好处是无论设备快慢,物体的运动速度都保持一致。我在做动效原型时特别依赖这个写法,尤其是需要在不同性能电脑上演示的场合。
3.3 createCanvas 与坐标系的坑
createCanvas(宽度, 高度)用来创建画布,它必须放在setup()里。画布默认是position: static的行内块元素,很多初学者发现画布不在预期位置,第一反应是改 JS 代码,实际上加几行 CSS 就够了,让画布固定居中是很常见的手法。
坐标体系是另一个容易踩坑的地方。p5.js 的坐标系原点在画布左上角,x 轴向右增大,y 轴向下增大。这和数学课上常见的坐标系是相反的。如果你过去习惯了“向右为正、向上为正”的思维,画出来的东西可能和你预期上下颠倒。解决方法是画之前先在纸上画一个坐标草图,不要凭感觉算。
还有一个隐藏坑:高清屏下坐标会被拉伸。如果用pixelDensity(2)显示到视网膜屏上,像素渲染更细腻,但同时你读取mouseX等坐标时会出现偏移。正确做法是使用createCanvas(w, h); pixelDensity(1);保持坐标和像素一一对应,或者统一用 p5.js 提供的高清适配方式。做基础项目时我一般直接关掉高密度渲染,避免视觉和交互坐标不一致。
4. 颜色系统与绘图基础:把每个像素都变成你的表达工具
4.1 RGB 与 HSB 两种颜色模式
p5.js 的颜色系统默认是 RGB,取值 0 到 255,fill(255, 0, 0)是红色。这个模型符合屏幕发光原理,但人对颜色的感知不是按红绿蓝通道来的,而是按“色相-饱和度-明度”更直观。p5.js 提供了colorMode(HSB)切换。
为什么要重点提 HSB?因为做渐变色、按数据映射颜色时,HSB 比 RGB 容易得多。比如你想实现一组从暖到冷的色彩过渡,只需改变色相 H 的值,从 0 到 200 平滑移动,S(饱和度)和 B(明度)保持固定,效果就非常自然。用 RGB 的话你得同时调整三个通道,很难调出连贯的渐变。
colorMode(HSB, 360, 100, 100); for (let i = 0; i < 360; i += 2) { stroke(i, 80, 100); line(i, 0, i, 100); }这里colorMode(HSB, 360, 100, 100)是很多初学者忽略的参数:它不只切换到 HSB,还把各通道的最大值重新设定了。如果漏掉最大值,后续传入的 360 会被当作超出范围,颜色结果会非常诡异。先统一colorMode(HSB, 360, 100, 100),再写所有颜色,是保持一致性最好的习惯。
4.2 常用形状函数与参数记忆法
p5.js 的基础形状函数数量不多,但参数很容易记混,尤其是圆和椭圆的参数。我整理过一个口诀:
rect(x, y, 宽, 高[, 圆角]):前两个参数是左上角坐标,不是中心点。ellipse(x, y, 宽, 高):前两个参数是中心点坐标。line(x1, y1, x2, y2):两个端点的坐标。point(x, y):单独点。vertex():配合beginShape()和endShape()自定义多边形。
最容易出错的其实是rect()的坐标定位。很多从其他库转过来的人默认矩形的 x、y 是中心点,但 p5.js 默认是左上角,导致矩形位置整体偏移。可以用rectMode(CENTER)改成以中心为基准,ellipseMode(CORNER)改成以左上角为基准,但一旦改了,全局所有同类形状都会受影响,建议在setup()里统一设置,不要在绘制中途频繁切换模式。
4.3 fill、stroke、strokeWeight 与透明度
p5.js 绘图时,形状由两部分构成:填充色fill()和描边色stroke(),描边粗细用strokeWeight()。这里有个很常见的困惑:为什么画了一个圆但是没有边框?因为你没有调用stroke()给它设置颜色。p5.js 默认描边是黑色,但当你调用noStroke()后再画别的圆,没重新stroke()回来,所有新图形都会没有边框。同理,noFill()之后不重新fill(),后续图形都是空心。
透明度是颜色第四维参数,RGB 模式下是fill(255, 0, 0, 100),其中 100 是 alpha 值,0 完全透明,255 完全不透明。用透明度叠加图形可以做出很漂亮的层次效果,比如粒子系统的拖尾模糊效果就是用半透明background()实现的:
function draw() { background(255, 20); // 每帧盖一层半透明白色 circle(random(width), random(height), 10); }这个技巧的神奇之处在于:如果background()是不透明的,画面会每帧清空,只能看到圆点瞬移;加上低透明度后,圆点的残影会逐渐淡出,形成拖尾轨迹,这是粒子系统非常经典的实现方式。
5. 交互与事件处理:让作品从“看”变成“玩”
5.1 mouseX、mouseY 与内置鼠标事件
p5.js 提供了全局变量mouseX和mouseY,在draw()里可以实时读取鼠标位置,这比传统前端的事件监听方式更直接。比如上一小节画圆圈跟随鼠标的代码,其实已经用到了这两个变量。
另外还有一批以“on 开头”的全局事件函数,最常用的三个是:
mousePressed():鼠标按下时执行一次。mouseReleased():鼠标松开时执行一次。mouseMoved():鼠标移动时执行。
你可能注意到,这些函数名和 p5.js 内部的事件循环是绑定关系,你只要在代码里定义了对应函数,系统就会自动在合适时机调用。这里有个新手常犯的错误:在draw()里不断判断mousePressed条件来控制状态,逻辑容易变得混乱。不如直接把状态管理放到事件函数里,draw()只负责根据状态画图。
let isPressed = false; function mousePressed() { isPressed = true; } function mouseReleased() { isPressed = false; } function draw() { background(200); if (isPressed) { fill(255, 0, 0); } else { fill(255); } circle(mouseX, mouseY, 30); }这段代码的效果是:按下鼠标时圆圈变红,松开后恢复。状态分离得干净,不会因为draw()执行频率高而出现状态判断错误。
5.2 键盘事件与 keyIsDown 的取舍
键盘事件同样有全局函数版本:keyPressed()和keyReleased()。与之配合的全局变量是key(存储当前按键的字符串)和keyCode(存储按键代码,比如LEFT_ARROW、RIGHT_ARROW)。
这里我需要提醒一个容易误导新手的点:网上很多教程会用if (keyIsDown(LEFT_ARROW))来检测方向键是否按住不放。keyIsDown()确实方便,但它会占用事件监听资源,而且如果你的程序里同时写了keyPressed()去检测其他按键,两个系统之间可能出现状态不同步的情况。我的建议是:能不用keyIsDown()就尽量不用,直接维护一个“按键状态字典”,在keyPressed()和keyReleased()里更新它,这样逻辑更透明,也方便调试。
let keys = {}; function keyPressed() { keys[keyCode] = true; } function keyReleased() { keys[keyCode] = false; } function draw() { if (keys[LEFT_ARROW]) { // 向左移动 } }这种自己维护状态的方式在实际项目中更可控,尤其是做游戏角色控制时,多个按键同时按下不会互相干扰。
5.3 鼠标拖拽绘制的完整案例
把点击、移动、绘制结合起来,可以做一个小小画板。这个案例能在几分钟内跑通,也是 p5.js 交互功能的浓缩展示。
let isDrawing = false; let lastX = 0, lastY = 0; function setup() { createCanvas(640, 480); background(255); strokeWeight(4); stroke(0); } function mousePressed() { if (mouseX > 0 && mouseX < width && mouseY > 0 && mouseY < height) { isDrawing = true; lastX = mouseX; lastY = mouseY; } } function mouseReleased() { isDrawing = false; } function draw() { if (isDrawing) { line(lastX, lastY, mouseX, mouseY); lastX = mouseX; lastY = mouseY; } }这段代码里的关键细节,是在mousePressed()里加了画布边界判断。不加的话,如果你在浏览器窗口的空白区域按住鼠标再移动到画布上,画布内的事件处理会被打断,线条会出现奇怪的断点。另外line()每帧画一条短线段,而不是连续的长直线,是为了让线条平滑地跟随鼠标轨迹。如果每帧只更新lastX和lastY,但用circle()绘制,效果是圆点笔刷;用line()绘制,则是钢笔线条。这个差异可以用来扩展不同的画笔表现。
6. 随机性与噪声:让作品拥有不那么僵硬的“生命力”
6.1 random() 与 randomSeed():可控的随机
创意编程里,随机数是不可或缺的元素。p5.js 提供random(下限, 上限),返回一个在区间内的浮点数;如果只写一个参数,则返回 0 到该参数之间的浮点数;如果想取整数,可以用floor(random(1, 6))或random(1, 6)配合round()处理。
随机数最大的问题是不可控,下次运行结果完全不同。如果你要做“同一份代码,生成不同但可复现的画面”,就要用到randomSeed(种子值)。在setup()里调用一次,之后所有random()的结果都会变成确定的序列。
function setup() { randomSeed(42); createCanvas(400, 400); background(0); for (let i = 0; i < 20; i++) { fill(random(255), random(255), random(255)); ellipse(random(width), random(height), 20, 20); } }这种确定性非常有用,比如你在生成一个艺术图案,发现第 17 次运行的效果最好,那你只要把种子号记下来,就能随时复现这个版本。实际做“生成艺术”项目时,我一般会把种子号作为外部输入参数,这样每次运行既随机又能被记录,效果非常可控。
6.2 noise():比 random() 平滑一百倍的伪随机
random()的问题是相邻两个随机数之间毫无关联,画出来的轨迹非常跳跃。比如你想让一个球在屏幕上随机移动,用random()会让它像喝醉了一样乱跳,而noise()能让它像自然飘动的叶子一样平滑。
noise()返回一个 0 到 1 之间的值,但它的核心特点是“相邻的自变量对应的结果也接近连续变化”。它内部使用 Simplex Noise 算法,生成的多维噪声更接近自然界的纹理和运动方式。基本用法是传入一维、二维或三维坐标参数:
let t = 0; function draw() { let x = noise(t) * width; let y = noise(t + 1000) * height; circle(x, y, 20); t += 0.01; }这里我传入了两个不同的噪声偏移量t和t + 1000,目的是让 x 和 y 的噪声序列互不相关,否则两个坐标会同步变化,球只会沿对角线移动。t += 0.01这个步长决定了运动的细腻程度,步长太大,运动轨迹会变粗糙;步长太小,球会走得非常慢。实际调试时,我会把步长设成一个变量,先试 0.02,再试 0.005,选择视觉上最自然的运动速度。
7. 常见问题与排查技巧实录
7.1 画布不显示或只显示一片空白
最常见的两个原因:一是忘了写createCanvas();二是draw()里第一行就是background(),但画布尺寸没设置,默认是 100 x 100 像素,看起来像没画。还有一个隐藏原因是 p5.js 的库没加载成功,导致<script>标签里的代码报错,但控制台里可能只会看到一个“p5 is not defined”之类的提示。无论哪种情况,先看浏览器控制台,再确认画布尺寸和background()是否被调用。
7.2 图形一闪而过或被“吞掉”
如果你发现图形只在第一帧出现,然后消失,说明你的draw()里画了图形,但下一帧又画了background(),直接把上一帧的内容清掉了。这其实不是 bug,而是画笔模式的正常表现。解决方案是要么在draw()里保留背景并重新绘制所有图形,要么把background()从draw()里挪到setup()里只执行一次。前者适合动态画面,后者适合静态或者需要叠加痕迹的画面。
7.3 颜色和预期完全不一样
大部分颜色问题都出在colorMode上。比如你刚切换到 HSB 模式后没用colorMode(RGB)切回来,后面所有fill()里的 RGB 值都会被当作 HSB 值解释。还有前面提到的colorMode(HSB, 360, 100, 100)参数,如果最大值不统一,颜色的结果也会非常奇怪。排查时先打印当前的颜色模式,再检查每个fill()是否在你预想的模式下。
7.4 绘制卡顿与性能优化入门
如果画面明显掉帧,先从这几个方向排查:是否在draw()里执行了background()的全画布重绘?是否每帧创建了新的数组或对象?是否在循环里调用了耗时的loadImage()?
常用的性能优化手段包括:把不需要每帧重新计算的变量提前到setup();用noLoop()暂停循环,只在需要刷新时调用redraw();尽量复用已有的对象,避免在draw()里频繁实例化。对于纯 2D 绘画项目,p5.js 的性能上限其实不低,但如果你的粒子系统要同时渲染几千个对象,建议把绘制函数简化,或者考虑用 WebGL 模式,不过那是进阶话题了。
7.5 一些容易忽略但很实用的小技巧
写 p5.js 有一个非常容易被忽视的习惯:用push()和pop()包裹临时的样式设置。比如你画一个图形前设置了fill(255, 0, 0),画完后想让后续图形恢复默认白色填充,在fill()外面包一对push()/pop()就能避免样式互相污染。
push(); fill(255, 0, 0); ellipse(50, 50, 20); pop(); // 这里 fill 又恢复成进入 push() 之前的值这个习惯在复杂画面里非常关键。如果你不包,画完红色椭圆之后,后面所有形状都会变成红色,你只能一遍遍手动设置颜色,代码又乱又容易出 bug。我在做稍微复杂点的作品时,几乎每个绘制函数里都会用到push()和pop(),这已经是肌肉记忆级别的基础操作了。
另外,width和height这两个全局变量非常方便,它们始终等于createCanvas()创建出来的画布尺寸,用来做居中、边界检测、随机坐标都很好用,不需要你手动再存一份。
一些我自己的收尾体会
p5.js 的入门曲线非常平缓,但真正用好它,需要你建立一套新的思考方式:画面是每帧重画出来的,状态要显式管理,样式要防止串扰,坐标要时刻清楚原点和方向。我见过很多新手在这些地方碰壁,其实都不是复杂问题,多半是没把绘制循环的运行机制吃透。
我个人最大的一个建议是:初期不要贪多,别急着学粒子系统、物理引擎这些花哨插件。先把setup()/draw()、基础形状、颜色模式、鼠标键盘事件、随机与噪声这五个板块写熟,做几个小作品,你会发现进阶内容基本都是这些基础模块的组合。后面的系列内容还可以继续拆图像加载、像素操作、类与对象组织、WebGL 渲染等进阶主题,这些都是可以一步步展开的部分,但前提是这次讲的基础你切切实实练过了。
写到这里,我想到一个自己常用来“检验掌握程度”的小挑战:试着用纯 p5.js 写一个画面:背景是平滑流动的彩色渐变,鼠标按下去会溅开一圈粒子,松开后粒子慢慢消失。如果这个效果你能不查文档写出来,就有足够的底气继续往下学了。