创意编程 p5.js 快速入门

发表于 更新于

前言

假设你是一个天文爱好者,是否有想过用代码来模拟行星运动?假设你是一位设计师,是否有想过用代码来创作艺术作品?假设你是一名教师,是否有想过用代码呈现教学内容?

p5.js 可以帮助你实现这些想法,让创意通过代码变为现实。

概述

前言内容是我个人对“创意编程”的理解。我认为 p5.js 可以承担这个角色,帮助我们实现创意编程。并且这个库相当的易于使用,门槛很低,适合非专业群体。

p5.js 的核心是 2D/3D 绘图和动画功能,它是 Processing 在 JavaScript 上的实现,可以在浏览器中运行。包含基本图形(点线面)、颜色、文本、多媒体纹理等,一些辅助计算的状态属性和工具函数。足以满足一般的视觉和动画设计。我相信任何具备一定计算机基础的人,都能或多或少的掌握 p5.js,并学到一些新东西。

后文我会将 p5.js 简化称作 p5,请留意。

编辑器

访问官方的在线编辑器页面可以立即进入创作,它非常适合学习阶段的轻量级使用。在左侧的编辑器写入代码,点击三角图标运行,右侧即可看到效果。

当你更加深入 p5 后,可切换到本地的模板环境,使用更专业的 VS Code 等编辑器进行开发。因为官方的在线编辑器不够好用。

如果后续我有开发 p5 模板,我可能会在此处粘贴地址并提供用法。

实际上我的博客就是一个巨大的 p5 模板,你会看到后文一个个的 p5 草图直接呈现在文章中。

基础学习

这是一个大章节,包含 p5 的各个主要功能。请按顺序从上往下看,因为内容是是逐步深入的。本章节需要提前掌握 JavaScript 基础,请至少学习并掌握 JS 中的变量、函数、流程控制、面向对象(class)等基础知识。

同时要留意后续章节的代码中的注释文字,它们也是重要的教学内容。

草图

通常我们称单个 p5 实例为“草图”,它至少包含 setupdraw 两个函数。我们在 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); // <- 卫星的大小是行星的一半
}

这个草图的重点是三角函数 cossin 的使用。这两个函数常用于计算圆周座标,返回 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

backgroundfill 等函数的颜色参数存在第四个值时,它表示 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 必须在 beginShapeendShape 两个函数调用的之间范围内使用。

闪电小屋

虽然我们只学习了一种曲线,但也可以做一些复杂的事情了,因为 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 几乎每一个主要概念都有数个同类型概念,例如曲线文本只介绍了 curveVertexcos/sin 的角度模式未提及等等。

下面是一些深入的链接,建议加入到收藏夹:

你尤其有必要多多查阅 API 文档。本文对函数参数的解释并不详细(否则篇幅太长),你需要看相关函数的文档才能足够了解。无论深浅,API 文档都是必要的手册。不查阅 API 文档很多时候就如同盲人摸象。

规范编码

本文的示例代码为了节省行数,大量硬编码了数值。这其实是不好的,因为硬编码不利于修改和维护,也不能直观的表示数值之间的关系。举个例子:

createCanvas(412, 402); // <- 创建画布

circle(206, 201, 100); // <- 绘制居中于画布的圆

这里的 412/402206/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 项目,它远比在线编辑器强大和现代。

版权/许可

本文的示例代码可以在免费、开源的使用条件下修改和分发。内容可以随意转载,但要包含原始链接。如若平台不允许外部链接,则提及本博客。

同时本文的内容禁止用于付费的课程、书籍、培训等商业用途,但不包括公益性质的培训教育。

结束语

推荐将本文加到收藏夹,以便日后查阅。如果你有任何问题或建议,也欢迎联系我。

作者头像 一点点入门知识 打赏作者
订阅我的 Telegram 频道

订阅频道第一时间掌握作者博客的最新动态,获取更多的分享。

本文无明确许可,转载前请获得授权。
分享:

相关文章