创意编程 p5.js 快速入门
前言
假设你是一个天文爱好者,是否有想过用代码来模拟行星运动?假设你是一位设计师,是否有想过用代码来创作艺术作品?假设你是一名教师,是否有想过用代码呈现教学内容?
p5.js 可以帮助你实现这些想法,让创意通过代码变为现实。
概述
前言内容是我个人对“创意编程”的理解。我认为 p5.js 可以承担这个角色,帮助我们实现创意编程。并且这个库相当的易于使用,门槛很低,适合非专业群体。
p5.js 的核心是 2D/3D 绘图和动画功能,它是 Processing 在 JavaScript 上的实现,可以在浏览器中运行。包含基本图形(点线面)、颜色、文本、多媒体纹理等,一些辅助计算的状态属性和工具函数。足以满足一般的视觉和动画设计。我相信任何具备一定计算机基础的人,都能或多或少的掌握 p5.js,并学到一些新东西。
后文我会将 p5.js 简化称作 p5,请留意。
编辑器
访问官方的在线编辑器页面可以立即进入创作,它非常适合学习阶段的轻量级使用。在左侧的编辑器写入代码,点击三角图标运行,右侧即可看到效果。
当你更加深入 p5 后,可切换到本地的模板环境,使用更专业的 VS Code 等编辑器进行开发。因为官方的在线编辑器不够好用。
如果后续我有开发 p5 模板,我可能会在此处粘贴地址并提供用法。
基础学习
这是一个大章节,包含 p5 的各个主要功能。请按顺序从上往下看,因为内容是是逐步深入的。本章节需要提前掌握 JavaScript 基础,请至少学习并掌握 JS 中的变量、函数、流程控制、面向对象(class
)等基础知识。
同时要留意后续章节的代码中的注释文字,它们也是重要的教学内容。
草图
通常我们称单个 p5 实例为“草图”,它至少包含 setup
和 draw
两个函数。我们在 setup
函数中创建“画布”,在 draw
函数中让画布“动画化”。
function setup() {
createCanvas(200, 200); // <- 创建画布
}
function draw() {
background(220); // <- 设置背景色
}
上面的代码创建了一个 200x200 的画布,背景色为灰色。呈现效果:
动画
你可能会奇怪,我们明明添加了 draw
函数,为什么画布没有任何动画?
好问题,因为我们的 draw
函数每一次重绘后都是相同的,它始终将背景色设置为 220
这个灰阶值(后文会介绍颜色)。既然每一次都是相同的,那么我们也就看不出任何动画过程了。
修改 draw
函数代码,让背景色随着帧数量而变化:
function draw() {
background(frameCount % 255); // <- 随着帧数量而变化灰阶值
}
现在我们的画布颜色在渲染每一帧时都会不同,并且由于帧数是递增的,会产生一个灰阶渐变的效果。如下:
这里提到的“帧”,它从哪里产生?
帧是 p5 自动回调 draw
函数产生的。默认 p5 会以每秒 60 帧的速度刷新画布内容,即每秒回调 draw
函数 60 次。若下一次 draw
函数执行的结果不同,那么我们就会在那一帧看到不同的画布内容。
通常我们会在 draw
函数中动态的改变画布元素的颜色、尺寸、座标等数据,产生不同类型的动画。但 setup
函数只被调用一次,所以它仅用于初始化。
frameCount
变量,这实际上是 p5 内置的一个属性,它代表帧的累计数字(渲染的总帧数)。我们经常使用这种外部会变化的变量来参与计算产生动画数据。基本形状
光折腾画布肯定是不够的,就好像一张不播放电影的幕布,即使它会变色也是无趣的。所以接下来我们学习 p5 提供的一些绘制基本图形的函数,如 point
(点)、line
(线)、rect
(矩形)、ellipse
(椭圆)等。组合这些图形可以绘制出各种复杂的图案:
这里我用到了矩形、椭圆、线和点,代码如下:
function setup() {
createCanvas(200, 200);
rectMode(CENTER); // <- 设置矩形的模式为中心
}
function draw() {
rect(100, 100, 20, 100); // <- 绘制一个矩形,座标 100, 100,宽 20,高 100
ellipse(100, 70, 60, 60); // <- 绘制一个椭圆,座标 100, 70,宽 60,高 60
ellipse(81, 70, 16, 32); // <- 绘制一个椭圆,座标 81, 70,宽 16,高 32
ellipse(119, 70, 16, 32); // <- 绘制一个椭圆,座标 119, 70,宽 16,高 32
line(90, 150, 80, 160); // <- 绘制一条线,起点座标 90, 150,终点座标 80, 160
line(110, 150, 120, 160); // <- 绘制一条线,起点座标 110, 150,终点座标 120, 160
point(81, 70); // <- 绘制一个点,座标 81, 70
point(119, 70); // <- 绘制一个点,座标 119, 70
}
几乎每一个画布里的元素都需要定位。对于 2D 元素,我们需要提供 x 和 y 座标。p5 或者说计算机图形中的座标系和我们在数学中学到的座标系并不同:
左侧的是笛卡尔座标系,0,0
位于中间。而右图计算机图形学中的座标系 0,0
位于左上角。所以你会发现我们上面的代码中,并没有负数座标值。
rectMode
函数修改矩形模式为 CENTER
。这里的 CENTER
是 p5 内置的一个常量,它表示矩形的座标以中心为基准。默认是 CORNER
,以矩形左上角为基准。圆周运动
我们已经掌握了形状和座标知识,是时候创建更复杂的动画了。大胆点,尝试模拟一个简单的卫星绕行星公转的过程:
从上面的动画中,我们看到一个小圆(卫星)围绕中心的大圆(行星)做逆时针圆周运动。仅靠现在掌握的知识已经可以实现,这是代码:
// 定义画布大小(正方形画布,长宽一致)
const canvasSize = 250;
// 计算居中位置
const centerP = canvasSize / 2;
function setup() {
createCanvas(canvasSize, canvasSize);
}
function draw() {
// 绘制行星
ellipse(centerP, centerP, 60, 60); // <- 圆默认以中心为基准,所以使用画布的中心位置就可以将圆放到中间。
const satX = centerP + cos(-frameCount * 0.01) * 100; // <- 计算卫星的 x 座标
const satY = centerP + sin(-frameCount * 0.01) * 100; // <- 计算卫星的 y 座标
// 绘制卫星
ellipse(satX, satY, 30, 30); // <- 卫星的大小是行星的一半
}
这个草图的重点是三角函数 cos
和 sin
的使用。这两个函数常用于计算圆周座标,返回 0 - 1 之间的值。将返回值放大 100 倍,即可得到一个合理的半径长度(座标和中心的距离)。
由于输入给三角函数的参数(帧数的负数 * 0.01)一直在递减,所以代表卫星的小圆的座标一直在圆周上变化,即圆周运动。注意,此处的 0.01
起到减缓运动速度的作用(让三角函数的返回值过渡更加平滑),你可以调整这个值来改变运动速度。
cos
/sin
两个函数可以定位圆周座标,分别用于 x 和 y。同时,它们也是实现往复式运动常见的工具函数。canvasSize
的值,改变画布大小的同时不影响元素的居中效果。类似的,你还可以加上卫星和质心的距离变量,用于调节卫星的远近。颜色
颜色是视觉艺术的重要组成部分,p5 提供了多种颜色表示方式,包括 RGB/RGBA、HSB/HSBA、HSL/HSLA 等。这是一个综合的例子:
上面的图形总共展示了 6 种颜色使用方式,这是代码:
function setup() {
createCanvas(260, 100);
}
function draw() {
colorMode(RGB); // <- 改回 RGB 颜色模式
background(200); // <- 单个数字参数,作为灰阶
noStroke();
fill(255, 0, 0); // <- RGB 颜色,红色
circle(30, 50, 40);
colorMode(HSB); // <- 改为 HSB 颜色模式
fill(50, 55, 100); // <- HSB 颜色,黄色
circle(80, 50, 40);
fill("hsl(160, 100%, 50%)"); // <- HSL 颜色,绿色
circle(130, 50, 40);
fill("cyan"); // <- CSS 颜色,绿色
circle(180, 50, 40);
fill("#4A58DF"); // <- Hex 颜色,蓝色
circle(230, 50, 40);
}
我们首先设置了颜色模式和背景色,然后连续创建了五个不同颜色的圆,它们的颜色使用方法各不相同。fill
函数调用以后的所有图形都会具有这个颜色,直到下一个 fill
函数调用。这就是为什么我们要先调用 fill
再调用 circle
。
当 background
、fill
等函数的颜色参数存在第四个值时,它表示 Alpha,你可以认为是透明度。
RGB
,实际这是默认的颜色模式。但在我们的代码中,这是必要的,因为后续对颜色模式的修改会造成全局影响。如果不手动再次设置回 RGB
那么颜色模式将始终是 HSB
(包括下一帧)。地球公转
现在,我们掌握了颜色知识。再次改写圆周运动章节的代码,让其包含颜色。这次我将大圆视作太阳,小圆视作地球,并加上公转轨道的显示。新的动画如下:
可以看到这次我们的小圆变成了蓝色(地球的颜色),大圆变成了橙色(太阳的颜色),还有一圈黄色的轨道线。
代码如下:
function setup() {
createCanvas(250, 250);
}
function draw() {
// 使用背景色覆盖上一个画布
background(0);
// 去掉描边
noStroke(); // <- 让后续形状没有描边
// 绘制太阳
fill("orange");
circle(125, 125, 60);
// 绘制轨道线
stroke("yellow"); // <- 设置描边
noFill(); // <- 去掉填充,因为轨道是一个圈,而不是实心圆。
circle(125, 125, 200);
const earthX = 125 + cos(-frameCount * 0.01) * 100; // <- 计算地球的 x 座标
const earthY = 125 + sin(-frameCount * 0.01) * 100; // <- 计算地球的 y 座标
// 绘制地球
noStroke(); // <- 再次去掉描边
fill("blue");
circle(earthX, earthY, 30);
}
细心的你可能还会注意到,相比于圆周运动章节的动画,现在的卫星环绕不会产生黑色的轨迹。这是因为我们在 draw
代码的最前面调用了 background
函数,它会覆盖(清除)上一帧的画布内容。这样每一帧绘制出来的就是新的地球,没有旧地球叠加,故不会留下“轨迹”。
曲线
曲线包括弧线(arc
)、样条曲线(curve
)和贝赛尔曲线(bezier
)等。曲线就像画笔一样,可以勾勒出更自然的不规则图形。
使用 curveVertex
函数可以生成更复杂的样条曲线,如同多条曲线被平滑的连接在一起。代码如下:
const coords = [40, 40, 80, 60, 100, 100, 60, 120, 50, 150];
function setup() {
createCanvas(250, 200);
}
function draw() {
background(255);
noFill();
stroke(0);
// 绘制样条曲线
beginShape();
curveVertex(40, 40); // <- 开始锚点
curveVertex(40, 40); // <- 第一个点(和锚点重合)
curveVertex(80, 60); // <- 第二个点
curveVertex(100, 100); // <- 第三个点
curveVertex(60, 120); // <- 第四个点
curveVertex(50, 150); // <- 第五个点
curveVertex(50, 150); // <- 结束锚点(和第五个点重合)
endShape();
// 绘制闭合的样条曲线
fill(200);
beginShape();
curveVertex(110 + 40, 40);
curveVertex(110 + 40, 40);
curveVertex(110 + 80, 60);
curveVertex(110 + 100, 100);
curveVertex(110 + 60, 120);
curveVertex(110 + 40, 40);
curveVertex(110 + 40, 40);
endShape();
// 在节点位置绘制圆,辅助理解
noFill();
for (let i = 0; i < coords.length; i += 2) {
ellipse(coords[i], coords[i + 1], 10, 10);
}
}
此处的 curveVertex
调用指定了每一个节点座标,节点相连的图形就是我们需要的曲线。若两个锚点座标相同,那么我们可以让曲线首尾相连,形成一个闭合的图形。
curveVertex
创建曲线需至少包含两个锚点与两点节点(四个点),锚点是可见线段的起点和终点。当第一个点或最后一个点和锚点重合时,看上去好像代码有冗余,实则具有意义。curveVertex
必须在 beginShape
和 endShape
两个函数调用的之间范围内使用。闪电小屋
虽然我们只学习了一种曲线,但也可以做一些复杂的事情了,因为 curveVertex
相当的灵活!我创造了一个不断有闪电劈下的场景,还包含一个被天打雷劈的可怜小屋:
鼠标移动到画布中激活动画
闪电是用样条曲线实现的,曲线的 y 轴向下延伸,同时不断的随机生成 x 轴座标。闪电劈下后,小屋的门和夜空会被照亮。完整代码如下:
// 闪电座标的数据容器
const lightningCoords = [];
// 闪电尾部的 x 座标(闪电从这个点向下延伸)
let lastTailX = 200;
// 闪电间隔时间(毫秒)
let lightningInterval = 1000;
// 上次闪电时间
let lastLightningTime = 0;
// 重新生成闪电数据
function regenLightningCoords() {
lightningCoords.length = 0;
lastTailX = random(150, 250);
for (let y = 0; y <= 396; y += 2) {
lastTailX = lastTailX + random(-4, 4);
lightningCoords.push([lastTailX, y]);
}
}
function setup() {
createCanvas(400, 400);
regenLightningCoords();
lastLightningTime = millis(); // <- 初始化为当前时间
rectMode(CENTER);
}
function draw() {
noFill(); // <- 去掉填充,闪电只是一根曲折的线
stroke(255); // <- 闪电的颜色
background(0);
let currentTime = millis(); // <- 当前时间变量
// 判断是否到达下一次闪电的时间点
if (currentTime - lastLightningTime >= lightningInterval) {
lastLightningTime = currentTime; // 更新上一次闪电的时间
regenLightningCoords(); // 生成新的闪电数据
}
beginShape(); // <- 开始绘制闪电
// 起始锚点(将第一个点作为锚点)
const [firstX, firstY] = lightningCoords[0];
curveVertex(firstX, firstY);
const i = (currentTime - lastLightningTime) * 4; // 根据剩余时间计算数据位置
if (i >= 396) {
// 如果数据位置超过 396,直接绘制闪电的全部
for (let j = 0; j < lightningCoords.length; j++) {
const [x, y] = lightningCoords[j];
curveVertex(x, y);
}
curveVertex(lastTailX, 398);
curveVertex(lastTailX, 400);
curveVertex(lastTailX, 400); // <- 结束锚点
// 绘制闪电劈到底部时的背景
if (i / 5 < 160) {
background(i / 5);
} else {
background(160);
}
} else {
// 否则只绘制截止到当前位置的闪电部分
for (let j = 0; j < i / 2; j++) {
const [x, y] = lightningCoords[j];
curveVertex(x, y);
}
}
endShape(); // <- 结束绘制闪电
hut(i >= 396); // <- 绘制小屋
}
function hut(isLightning) {
noStroke();
// 绘制墙体
fill("#775E47");
rect(200, 400, 100, 150);
// 绘制屋顶
fill("black");
triangle(130, 325, 200, 300, 270, 325);
// 绘制门框
fill("#4E3D2D");
rect(200, 400, 24, 54);
// 绘制门
if (isLightning) {
fill("white");
} else {
fill("gray");
}
rect(200, 400, 20, 50);
// 绘制门把手
fill("black");
circle(205, 392, 4);
}
为了让闪电更自然,x 座标生成时,将始终基于上一个 x 座标(即闪电末尾的 x 座标值)随机偏移。另外,我们还用到了独立的自定义函数 hut
,里边封装了绘制小屋的代码。
还有一个要点。为了在帧动画中实现间隔一定时间才触发的动作,使用了 millis
函数参与计算。millis
始终返回自 setup
函数调用以来的毫秒数,我们使用变量 lastLightningTime
缓存上一次 millis
的值,和当前的 millis
对比即可判断是否符合执行条件。
如果你仍然对上面庞大的草图代码感到难以消化,那么我们可以简化成只有闪电的草图:
// 闪电座标的数据容器
const lightningCoords = [];
// 闪电尾部的 x 座标(闪电从这个点向下延伸)
let lastTailX = 200;
// 重新生成闪电数据
function genLightningCoords() {
lightningCoords.length = 0;
lastTailX = random(150, 250);
for (let y = 0; y <= 400; y += 2) {
lastTailX = lastTailX + random(-4, 4);
lightningCoords.push([lastTailX, y]);
}
}
function setup() {
createCanvas(400, 400);
genLightningCoords();
}
function draw() {
noFill(); // <- 去掉填充,闪电只是一根曲折的线
stroke(255); // <- 闪电的颜色
background(0); // <- 黑色背景
beginShape(); // <- 开始绘制闪电
// 起始锚点(将第一个点作为锚点)
const [firstX, firstY] = lightningCoords[0];
curveVertex(firstX, firstY);
for (let j = 0; j < lightningCoords.length; j++) {
const [x, y] = lightningCoords[j];
curveVertex(x, y);
}
curveVertex(lastTailX, 400); // <- 结束锚点
endShape(); // <- 结束绘制闪电
}
补充
此章节是在学完基础以后,推荐掌握的一些额外知识。它们通常是很常用,但非必要的内容。
向量
向量(Vector
)是座标的容器,它可以让我们在编写运动代码时避免手动更新每一个座标变量的值,并简化计算。
上面是一个圆从左上角运动到右下角的动画。使用 Vector 仅需调用 add
方法即可。add
方法会将两个向量相加,然后更新当前向量的值:
let pos;
let vel;
function setup() {
createCanvas(300, 300);
pos = createVector(0, 0); // <- 初始座标(0,0)
vel = createVector(2, 2); // <- 定义运动的速度(和方向)
}
function draw() {
background(200);
fill(0);
circle(pos.x, pos.y, 30); // <- 使用向量定位圆
pos.add(vel); // <- 更新座标(加上运动速度向量)
if (pos.x > width || pos.x < 0) {
pos.set(0, 0);
}
}
若不使用 Vector 则需要手动更新变量(例如 x += 2
)。因为 Vector 的 add
方法会自动更新此向量的值,所以我们不用担心这个。同时 Vector 提供了各种座标间计算的方法,如 mult
(相乘)和 dist
(距离)等等。
全文总结
本文主要介绍了 p5 的 2D 部分,尤其对动画进行了较为详细的讲解和展示。因为我本人对艺术没有特别的研究,所以教程更偏向于动画而不是绘图。实际上也有很多人用 p5 生成复杂的具有艺术感的图形,也是一个很有趣的领域。
更多资料
在掌握和理解了本文的知识后,你仍然要进一步深入。因为 p5 几乎每一个主要概念都有数个同类型概念,例如曲线文本只介绍了 curveVertex
,cos
/sin
的角度模式未提及等等。
下面是一些深入的链接,建议加入到收藏夹:
你尤其有必要多多查阅 API 文档。本文对函数参数的解释并不详细(否则篇幅太长),你需要看相关函数的文档才能足够了解。无论深浅,API 文档都是必要的手册。不查阅 API 文档很多时候就如同盲人摸象。
规范编码
本文的示例代码为了节省行数,大量硬编码了数值。这其实是不好的,因为硬编码不利于修改和维护,也不能直观的表示数值之间的关系。举个例子:
createCanvas(412, 402); // <- 创建画布
circle(206, 201, 100); // <- 绘制居中于画布的圆
这里的 412
/402
和 206
/201
就是硬编码数值,将它们改为变量和基于关系的计算更为合适:
// 定义尺寸变量
const width = 412;
const height = 402;
createCanvas(width, height); // <- 创建画布
circle(width / 2, height / 2, 100); // <- 绘制居中于画布的圆
现在我们可以自由修改画布尺寸而不影响圆的居中效果,还能从数值的计算关系中直观的了解到圆心的位置是长/宽的一半。
后续教程
本文已足够让读者了解 p5 的基础知识和 2D 的初级入门。如果你想继续学习,我会在后续的文章中继续讲解 p5 的进阶内容。
现有的后续教程如下:
后文将介绍后续教程的一个个大方向。
3D
实际上 p5 更富趣味也更加庞大的部分在于 3D,我会为 3D 功能再发表一篇(或多篇)教程文章。在 3D 教程中我会忽略初级知识。所以在学习 3D 之前,你也必须掌握好 2D 功能用途和概念。
互动性
p5 动画是可以具有互动性的,不仅仅局限于基于算法的自主运动。我后续会写一篇文章讲解如何让你 p5 画布元素和“人”互动起来。这将包括鼠标事件、键盘事件、触摸事件等。
三角函数
三角函数是 p5 中重要的函数之一,后续我会写一篇文章专门讲解三角函数的用途和原理。该文章将会是一个数学和编程的结合,希望能帮助你更好的理解三角函数。
本地开发
当你真正想做一些复杂的 p5 动画时,仅靠在线编辑器是不够的。后续我会写一篇文章教你如何在本地开发环境中开发 p5 项目,它远比在线编辑器强大和现代。
版权/许可
本文的示例代码可以在免费、开源的使用条件下修改和分发。内容可以随意转载,但要包含原始链接。如若平台不允许外部链接,则提及本博客。
同时本文的内容禁止用于付费的课程、书籍、培训等商业用途,但不包括公益性质的培训教育。
结束语
推荐将本文加到收藏夹,以便日后查阅。如果你有任何问题或建议,也欢迎联系我。
订阅频道第一时间掌握作者博客的最新动态,获取更多的分享。