canvas核心技术-如何绘制图形
July 18, 2018/ Edit on Github ✏️这篇学习和回顾 canvas 系列笔记的第二篇,完整笔记详见:canvas 核心技术
通过上一篇canvas 核心技术-如何绘制线段的学习,我们知道了如何去绘制线段。很多的线段的拼接就组成了图形了,比如常见的三角形,矩形,圆形等。
常见图形的绘制可以查看我的在线示例:canvas shape
示例项目仓库地址:canvas demo
图形
三角形
先来看看如何绘制一个三角形。三角形就是由三条边组成,我们可以理解为三个线段组成。确定了三角形的三个顶点的坐标位置,然后用线连接起来。
// 顶底1
let point1 = [100, 30]
// 顶点2
let point2 = [50, 100]
// 顶点3
let point3 = [180, 120]
// 开始一段新路径
ctx.beginPath()
// 移动起点到顶点1
ctx.moveTo(point1[0], point1[1])
// 连接顶点1与顶点2
ctx.lineTo(point2[0], point2[1])
// 连接顶点2与顶点3
ctx.lineTo(point3[0], point3[1])
// 描边
ctx.stroke()
// 绘制文本水平居中
ctx.textAlign = "center"
// 绘制顶点1文本
ctx.fillText(`(${point1[0]},${point1[1]})`, point1[0], point1[1] - 10)
// 绘制顶点2文本
ctx.fillText(`(${point2[0]},${point2[1]})`, point2[0] - 25, point2[1] + 5)
// 绘制顶点3文本
ctx.fillText(`(${point3[0]},${point3[1]})`, point3[0] + 30, point3[1] + 5)
从图可以看到,我们还有一条边没有连接起来,这是因为我们只显示的连接了 2 个顶点。要想把第三条边也连接起来,我们有 2 种方式。第一种方式是,我们显示的连接顶点 3 与顶点 1
// 第一种方式,显示的连接顶点3于顶点1
ctx.lineTo(point1[0], point1[1])
第二种方式是,我们调用ctx.closePath()
来按 canvas 自动帮我们连接未关闭的路径。
// 第二种方式,调用ctx.closePath()
ctx.closePath()
无论哪一种都可以实现我们想要三角形。其中第二种方式会用的比较多,因为它会帮我们自动关闭当前路径,也就是使当前路径形成一个闭合的路径,这个在填充时是非常有用的,下面会说的。最终,我们得到三角形图形如下
四边形
通过三个顶点,我们可以绘制一个三角形,那么通过四个点,我们当然可以绘制出四边形,我们照例来通过四个点来绘制一个矩形。
// p1
let point1 = [80, 30]
// p2
let point2 = [180, 30]
// p3
let point3 = [80, 110]
// p4
let point4 = [180, 110]
// 设置描边颜色为绿色
ctx.strokeStyle = "green"
// 开始新的一段路径
ctx.beginPath()
// 移动起点到p1
ctx.moveTo(point1[0], point1[1])
// 连接p1与p2
ctx.lineTo(point2[0], point2[1])
// 连接p2与p4
ctx.lineTo(point4[0], point4[1])
// 连接p4与p3
ctx.lineTo(point3[0], point3[1])
// 关闭当前路径,隐士连接p3与p1
ctx.closePath()
// 描边
ctx.stroke()
// 绘制顶点
ctx.textAlign = "center"
ctx.fillText("p1", point1[0] - 10, point1[1] - 10)
ctx.fillText("p2", point2[0] + 10, point2[1] - 10)
ctx.fillText("p3", point3[0] - 10, point3[1] + 10)
ctx.fillText("p4", point4[0] + 10, point4[1] + 10)
注意,我们的顺序是 p1—> p2—> p4—> P3,由于矩形是一种特殊的四边形,在 canvas 中提供了一种方法可以快速创建一个矩形,如果知道了 p1 的坐标和矩形的宽度和高度,那么我们就可以确定了其他三个点的坐标。
// 快速创建矩形
ctx.rect(point1[0], point1[1], 100, 80)
在创建矩形,我们总是使用ctx.rect(left,top,width,height)
,但是绘制非矩形的四边形,还是得按照每个点去连接成线段来绘制。
圆与圆弧
圆形可以看作是无数个很小的线段连接起来的,但是通过去定顶点来绘制圆形,显然不现实。canvas 中提供了一个专门绘制圆形的方法ctx.arc(left,top,radius,startAngle,endAngle,antiClockwise)
。各个参数的顺序意思是,圆心坐标 X 值,圆心坐标 Y 值,半径,开始弧度,结束弧度,是否逆时针。通过指定和,就可以绘制一个完整的圆了。最后一个参数antiClockwise
对于图片的填充时会非常有用,后面讲填充时会详细说到。
// 圆心坐标
let center = [100, 75]
// 半径
let radius = 50
// 开始弧度值
let startAngle = 0
// 结束弧度值,360度=Math.PI * 2
let endAngle = Math.PI * 2
// 是否逆时针
let antiClockwise = false
// 描边颜色
ctx.strokeStyle = "blue"
ctx.lineWidth = 1
ctx.arc(center[0], center[1], radius, startAngle, endAngle, antiClockwise)
// 将圆形描边绘制出来
ctx.stroke()
// 绘制出圆心和半径示意图,读者可以忽略下半部代码
ctx.beginPath()
ctx.fillStyle = "red"
ctx.arc(center[0], center[1], 2, startAngle, endAngle, antiClockwise)
ctx.fill()
ctx.beginPath()
ctx.moveTo(center[0], center[1])
ctx.lineTo(center[0] + radius, center[1])
ctx.stroke()
ctx.fillStyle = "blue"
ctx.font = "24px sans-serif"
ctx.textAlign = "center"
ctx.fillText("r", center[0] + radius / 2, center[1] - 10)
我们还可以改变起始和结束弧度的值,来绘制不同角度的弧形。比如八分之一圆弧,四分之圆弧,半圆弧等。
// 圆心坐标
let center = [50, 75]
// 半径
let radius = 20
// 起始弧度为0
let startAngle = 0
// 是否逆时针
let antiClockwise = false
// 弧度长度
let angles = [1 / 8, 1 / 4, 1 / 2, 3 / 4]
// 描边颜色
let colors = ["red", "blue", "green", "orange"]
for (let [i, angle] of angles.entries()) {
// 计算结束弧度
let endAngle = Math.PI * 2 * angle
// 设置描边颜色
ctx.strokeStyle = colors[i]
// 开始新的路径
ctx.beginPath()
// 绘制圆弧
ctx.arc(
center[0] + i * radius * 3,
center[1],
radius,
startAngle,
endAngle,
antiClockwise
)
// 描边
ctx.stroke()
}
任意多边形
上面说的都是一些比较简单和常见的图形,我们如何可以绘制任意多边形,比如五边形,六边形,八边形等。其实,在绘制四边形的时候就说过了,可以通过确定顶点坐标,然后把这些顶点按照一定顺序连接起来就可以了。下面,来实现一个通用的多边形的绘制方法。
class Polygon {
constructor(ctx, points) {
this.ctx = ctx
this.points = points
}
draw() {
if (!this.ctx instanceof CanvasRenderingContext2D) {
throw new Error(
"Polygon#ctx must be an CanvasRenderingContext2D instance"
)
}
if (!Array.isArray(this.points)) {
throw new Error("Polygon#points must be an Array")
}
if (!this.points.length) {
return
}
let firstPoint = this.points[0]
let restPoint = this.points.slice(1)
ctx.beginPath()
ctx.moveTo(firstPoint[0], firstPoint[1])
for (let point of restPoint) {
ctx.lineTo(point[0], point[1])
}
ctx.closePath()
}
}
通过实例化这个Polygon
,并传入多边形的顶点坐标,我们就可以绘制出不同的多边形。例如下面的代码,分别绘制了五边形,六边形。
// 绘制五边形
let points = [[30, 40], [80, 40], [100, 80], [55, 120], [10, 80]]
let pentagon = new Polygon(ctx, points)
ctx.strokeStyle = "blue"
pentagon.draw()
ctx.stroke()
// 绘制六边形
points = [[160, 40], [210, 40], [230, 80], [210, 120], [160, 120], [140, 80]]
let hexagon = new Polygon(ctx, points)
ctx.strokeStyle = "green"
hexagon.draw()
ctx.stroke()
填充
上面,我们都是用描边把图形绘制出来,还有一种用的比较多的就是填充了。填充就是用特定的颜色把图形包围的区域涂满。
// 顶底1
let point1 = [100, 30]
// 顶点2
let point2 = [50, 100]
// 顶点3
let point3 = [180, 120]
// 用红色描边
ctx.strokeStyle = "red"
// 用黄色填充
ctx.fillStyle = "yellow"
// 设置线段宽度为2
ctx.lineWidth = 2
// 开始一段新路径
ctx.beginPath()
// 移动起点到顶点1
ctx.moveTo(point1[0], point1[1])
// 连接顶点1与顶点2
ctx.lineTo(point2[0], point2[1])
// 连接顶点2与顶点3
ctx.lineTo(point3[0], point3[1])
// 关闭当前路径
ctx.closePath()
// 描边
ctx.stroke()
// 填充
ctx.fill()
需要注意的是,如果当前路径没有关闭,那么会先默认关闭当前路径,然后在进行填充 ,如下,我们把ctx.closePath()
注释掉。
let point1 = [100, 30]
let point2 = [50, 100]
let point3 = [180, 120]
ctx.strokeStyle = "red"
ctx.fillStyle = "yellow"
// 设置线段宽度为2
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(point1[0], point1[1])
ctx.lineTo(point2[0], point2[1])
ctx.lineTo(point3[0], point3[1])
// ctx.closePath();
ctx.stroke()
ctx.fill()
如果当前路径是循环的,或者是包含多个相交的子路径,那么 canvas 何如进行填充呢?比如下面这样的,为何在填充时,中间这一块没有被填充?
let point1 = [100, 30]
let point2 = [50, 100]
let point3 = [180, 120]
let point4 = [50, 60]
let point5 = [160, 80]
let point6 = [70, 120]
ctx.strokeStyle = "red"
ctx.fillStyle = "yellow"
ctx.lineWidth = 2
ctx.beginPath()
// 绘制三角形1, 顺序:p1--p2--p3--p1
ctx.moveTo(point1[0], point1[1])
ctx.lineTo(point2[0], point2[1])
ctx.lineTo(point3[0], point3[1])
ctx.lineTo(point1[0], point1[1])
// 绘制三角形2,顺序:p4--p5--p6--p4
ctx.moveTo(point4[0], point4[1])
ctx.lineTo(point5[0], point5[1])
ctx.lineTo(point6[0], point6[1])
ctx.lineTo(point4[0], point4[1])
ctx.stroke()
ctx.fill()
我们来具体研究一下fill
函数,查看MDN上的解释,
The
CanvasRenderingContext2D
.fill() method of the Canvas 2D API fills the current or given path with the current fill style using the non-zero or even-odd winding rulevoid ctx.fill([fillRule]); void ctx.fill(path[, fillRule]);
fillRule 参数是可选的,可取值为nonzero
,evenodd
。也就是说,fill
函数可以给当前路径或者给定的路径,使用非零环绕规则或者奇偶规规则来填充。path 参数是一个Path2D
对象,是一个给定的路径,canvas 中默认的是当前路径,这个参数并不是所有的浏览器都支持,目前看,还有 IE 系列和移动设备上都没有很好的支持,就不多说了,具体可以查看Path2D。
非零环绕规则
对于路径中的任意给定区域,从该区域内部画出一条足够长的线段,使此线段的终点完全落在路径范围之外。接下来,将计数器初始化为 0,然后,每当这条线段与路径上的直线或者曲线相交时,就改变计数器的值。如果是与路径的顺时针部分相交,则加 1,如果是与路径的逆时针部分相交,则减 1。最后,如计数器的值不为 0,则此区域就在路径里面,调用fill
时,该区域被填充。如果计数器的最终值为 0,则此区域就不在路径里面,调用fill
时,该区域就不被填充。canvas 的fill
默认使用的就是这种非零环绕规则。
再来看看上图,为何中间交叉区域没有被填充。我们绘制了 2 个三角形,第一绘制顺序是 p1 —> p2 —> p3 —> p1,第二个绘制顺序是 p4 —> p5 —> p6 —> p4 。可以看到第一个三角形在绘制是逆时针方向的,第二个三角形绘制是顺时针方向的,中间相交区域的计数器最终值就为 0 了,所以不应该包含在这个路径中。
非零环绕规则演示可以查看我的示例:非零环绕示例
奇偶规则
跟非零环绕规则类似,都是从任意区域画出一条足够长的线,使此线段的终点完全落在路径范围之外。如果这个线段与路径相交的个数是奇数,则此区域包含在路径中,如果是偶数,则表示此区域不包含在路径中。
例如,我们把上面的例子改下,绘制第二个三角形的顺序改成逆时针 p4 —> p6 —> p5 —> P4,然后分别用非零环绕规则和奇偶规则来填充,看看效果。
// 绘制三角形2,注意顺序变了:p4-p6-p5-p4
ctx.moveTo(point4[0], point4[1])
ctx.lineTo(point6[0], point6[1])
ctx.lineTo(point5[0], point5[1])
ctx.lineTo(point4[0], point4[1])
ctx.stroke()
// 填充, 默认就是非零环绕规则
ctx.fill()
上面两个三角形的顺序都是逆时针,所以按照非零环绕规则,像个三角形的相交区域的计数器的最终值为-2,不为 0,则包含在路径中改,被填充了。
同样的顺序,我们在改用奇偶规则来填充。
// 填充, 改用奇偶规则
ctx.fill("evenodd")
小结
这篇我们主要学习了 canvas 中如何绘制图形,比如常见的三角形,四边形,圆心,以及任意多边形。在绘制图形时,有些比如矩形,圆形等 canvas 已经提供了内置的函数,ctx.rect()
和ctx.arc
可以直接绘制,但是对于任意多边形,我们则需要自己逐线段的绘制。
在绘制路径时,是有顺序的。理解 canvas 中路径,和当前绘制的顺序,就可以很好的理解了 canvas 中填充规则了。canvas 中填充有非零环绕规则和奇偶规则。对于同样的路径,不同的规则可能会产生不同的填充区域,是使用时,注意路径顺序就好了。
若有收获,小额鼓励