
如果你打算用 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 langzh-CN head meta charsetUTF-8 titlep5.js 快速开始/title script srchttps://cdn.jsdelivr.net/npm/p51.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 srcp5.min.js/script这时候要注意一个问题直接用浏览器双击打开 HTML 文件时由于本地文件安全策略某些资源比如以后你加载图片、json 数据可能被浏览器拦截。虽然纯绘图不受影响但一旦涉及loadImage()、loadJSON()就会报跨域错误。所以我建议从一开始就养成本地开发服务器的习惯。最简单的做法是在项目目录下运行下面这条命令需要本机有 Pythonpython -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 到 255fill(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 写一个画面背景是平滑流动的彩色渐变鼠标按下去会溅开一圈粒子松开后粒子慢慢消失。如果这个效果你能不查文档写出来就有足够的底气继续往下学了。