概述
(原文写自2024年10月9日,整理笔记所以现在发出)
HTML5 Canvas并不是一项很新的技术了,但是作为一名业余程序员,我是不需要考虑新旧技术和投入实际生产的问题,相反,我只需要考虑有趣,什么有趣搞什么。在Godot中玩味了一圈CanvasItem
的绘图函数,也慢慢补齐了一点三角函数、向量和线性变换的基础之后,发现绘图才是程序中最有意思的内容。
关于Canvas的2D绘图指令,确实没有必要重复讲述,有很好的文章和在线文档讲述这些内容。相反,一些除绘图指令之外的用法是非常值得进阶学习的,因为我有Godot中的一些经验,理解和运用这些内容也变得非常容易,这大概就叫做“触类旁通”吧。
本文部分参考《HTML5 Canvas核心技术——图形、动画与游戏开发》(下文简称《HC开发》)一书,MDN文档、菜鸟教程和其他各处博文等。
本文的主要目标是试图精炼Canvas 2D绘图的一些高级和核心内容,并将其封装为一个自定义类的方法,从而简化原来的大量基础绘图代码,并且作为后续高级应用开发的基础。你可以看到我糅合了很多Godot中的思路和做法,之前封装JS版本的Vector2
类就是我无法忘掉从Godot中学习到的内容。
另外推荐渡一教育的几个Canvas视频,我觉得是目前讲的最牛最清晰的一个,而且也是最接近《HC开发》一书内容的,可以作为速通和辅助学习视频。渡一教育的视频在B站、小红书等社交账号都可以找到。
本文的最大特点,一个是业余,一个就是会讲述整个自定义类逐渐添加和封装Canvas核心功能的过程和思路。并且会以小tip的形式补充大量JavaScript的基础知识点(毕竟我的JavaScript基础也不是很牢固)。
动态创建canvas
在2D绘制方面,最重要的是下面两句:
var canvas = document.createElement("canvas"); var ctx = canvas.getContext("2d");
复制
封装为ES6类
JavaScript中的类
ECMAScript(简称ES)是一种由Ecma International通过ECMA-262标准定义的脚本语言规范。ES5和ES6是这个规范的两个版本,分别代表JavaScript语言的两个不同的发展阶段。
在ES5中,JavaScript并没有原生的类(class)概念,但是可以通过构造函数和原型链(prototype)来模拟面向对象编程中的类。而在ES6中,JavaScript正式引入了类(class)的概念,提供了一种更简洁和直观的方式来实现面向对象编程,但是其本质还是通过构造函数和原型链(prototype)实现。
可以看到,很多脚本和语言都有趋同化的设计,如果你将JavaScript和Python以及GDSCript放在一起,会发现很多相似的东西,毕竟思路和用途都大差不差。
以下是一个简单的ES6风格的类定义形式和实例化用法:
class 类名{ constructor(参数列表){ this.属性A = 参数1 this.属性B = 参数2 //... } 方法(参数){ //... } } let 实例 = new 类名(参数列表);
复制
- 类名一般首字母大写
constructor()
是类的构造函数,可以传入一些参数,为属性进行初始的赋值- 构造函数和一般的方法都不需要带
function
关键字 new 类名(参数列表)
而不是类名.new()
,习惯了GDSCript,很容易写错
我们依照ES6风格的类定义形式,定义一个初步的·Canvas2D
类型如下:
// Canvas2D.js // 2D canvas 辅助类 class Canvas2D{ constructor(width,height,p_node = document.body){ this.canvas = document.createElement("canvas"); this.ctx = this.canvas.getContext("2d"); this.canvas.width = width; this.canvas.height = height; p_node.append(this.canvas); } }
复制
其中:
Canvas2D
的构造函数,有三个参数:width
和height
分别指定<canvas>
的画布宽度和高度p_node
指定<canvas>
标签的父元素,默认为document.body
如上定义后,我们只需要在测试代码中new
一个Canvas2D
的实例,并传入宽高和父元素,就可以自定在测试页面的<body>
标签或其他元素中添加一个<canvas>
标签,Canvas2D
实例会在其canvas属性中存储对<canvas>
标签实例的引用。
// draw.js var canvas = new Canvas2D(200,200);
复制
因为需要再浏览器中使用和测试,所以我们搭建如下的测试页面:
<!-- test.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Canvas2D测试</title> <script src="Canvas2D.js"></script> <style> canvas{ box-shadow: 1px 1px 5px #ccc; } </style> </head> <body> </body> <script src="draw.js"></script> </html>
复制
其中:
- 在
<head>
部分引入Canvas2D.js
,并且用<style>
直接定义所有canvas
标签的统一样式,为其添加一个box-shadow
,用于在HTML页面中与白色背景区分 - 在
<body>
外,引入draw.js
,作为测试代码
测试效果:
使用Getter和Setter
使用set
和get
关键字,可以定义属性的Getter和Setter方法,用于更细节的控制属性的读写操作。
这里我们将Canvas2D
的width
和height
属性设定为读写Canvas2D.canvas
的width
和height
属性,以简化代码:
// Canvas2D.js // 2D canvas 辅助类 class Canvas2D{ constructor(width,height,p_node = document.body){...} set width(val){ this.canvas.width = val; } get width(){ return this.canvas.width; } set height(val){ this.canvas.height = val; } get height(){ return this.canvas.height; } }
复制
这样我们就可以直接像下面这样重新定义和读取canvas的尺寸:
// draw.js var canvas = new Canvas2D(200,200); // 通过height和width属性重新设定canvas的尺寸 canvas.height = 400; canvas.width = 300; // 读取canvas的尺寸 console.log(canvas.height); console.log(canvas.width);
复制
编写绘图方法
在搞定canvas的实例化之后,我们开始正式封装一些绘图方法。
draw_circle()
以绘制圆为例,封装一个方法如下:
// Canvas2D.js // 2D canvas 辅助类 class Canvas2D{ //... // ================= 方法 ================= draw_circle(cx,cy,radius,border = "#000",fill = "#fff",border_width = 1,dash = null){ let ctx = this.ctx; ctx.beginPath(); // 样式设定 ctx.strokeStyle = border; // 轮廓样式 ctx.lineWidth = border_width; // 轮廓线宽 ctx.fillStyle = fill; // 填充样式 if(dash != null && dash.length>0){ //虚线样式 ctx.setLineDash(dash); } // 主体路径 ctx.arc(cx,cy,radius,0,Math.PI * 2); // 填充和轮廓绘制 ctx.fill(); ctx.stroke(); } }
复制
测试:
// draw.js var canvas = new Canvas2D(200,200); canvas.draw_circle(100,100,50);
复制
// draw.js var canvas = new Canvas2D(200,200); canvas.draw_circle(100,100,50,"#444","#eee",2);
复制
// draw.js var canvas = new Canvas2D(200,200); canvas.draw_circle(100,100,50,"#FF5722","#F0F4C3",2,[5,10]);
复制
对draw_circle()的改进
可以看到对轮廓和填充样式的设定,以及调用fill()
和stroke()
进行绘制,对于每个绘图函数封装都是必须且重复的,所以可以将代码提炼出来,作为单独的方法。
// Canvas2D.js // 2D canvas 辅助类 class Canvas2D{ // ... // ================= 方法 ================= // 设定绘图样式 set_draw_style(border = "#000",fill = "#fff",border_width = 1,dash = null){ let ctx = this.ctx; ctx.beginPath(); // 样式设定 ctx.strokeStyle = border; // 轮廓样式 ctx.lineWidth = border_width; // 轮廓线宽 ctx.fillStyle = fill; // 填充样式 if(dash != null && dash.length>0){ //虚线样式 ctx.setLineDash(dash); } } // 填充和轮廓绘制 stroke_and_fill(border = "#000",fill = "#fff",border_width = 1){ let ctx = this.ctx; if(border != null && border_width > 0){ ctx.stroke(); } if(fill != null){ ctx.fill(); } } draw_circle(cx,cy,radius,border = "#000",fill = "#fff",border_width = 1,dash = null){ let ctx = this.ctx; // 设定绘图样式 this.set_draw_style(border,fill,border_width,dash); // 主体路径 ctx.arc(cx,cy,radius,0,Math.PI * 2); // 填充和轮廓绘制 this.stroke_and_fill(border,fill,border_width) } }
复制
进一步的还可以改进为:
// Canvas2D.js // 2D canvas 辅助类 class Canvas2D{ // ... // ================= 方法 ================= // 设定绘图样式 set_draw_style(border = "#000",fill = "#fff",border_width = 1,dash = null){ let ctx = this.ctx; ctx.beginPath(); // 样式设定 ctx.strokeStyle = border; // 轮廓样式 ctx.lineWidth = border_width; // 轮廓线宽 ctx.fillStyle = fill; // 填充样式 if(dash != null && dash.length>0){ //虚线样式 ctx.setLineDash(dash); } } // 填充和轮廓绘制 stroke_and_fill(border = "#000",fill = "#fff",border_width = 1){ let ctx = this.ctx; if(border != null && border_width > 0){ ctx.stroke(); } if(fill != null){ ctx.fill(); } } draw_circle(cx,cy,radius){ let ctx = this.ctx; // 主体路径 ctx.arc(cx,cy,radius,0,Math.PI * 2); } }
复制
两种方式各有利弊吧,第二种形式更像是将原来的设定样式工作和填充和轮廓绘制工作整体封装起来,绘图函数的参数也可以更加简化。
我更倾向于第一种设计,因为每绘制一个图形只需要调用一个方法,而不是两个。
绘制折线和多边形
Javascript不定参数函数设计
在ES6之前,JavaScript中处理不定参数的常用方法是使用arguments对象,ES6引入了Rest参数,用来创建更清晰和简洁的代码。Rest参数通过在参数名前加上…来表示,它将所有剩余的参数收集到一个数组中。
// Canvas2D.js // 2D canvas 辅助类 class Canvas2D{ // 直线和折线 draw_polyline(points,close = false,border = "#000",fill = "#fff",border_width = 1,dash = null){ let ctx = this.ctx; if(points.length>3 && points.length % 2 == 0){ // 至少有2个点,而且的数量是2的整数倍 // 设定绘图样式 this.set_draw_style(border,fill,border_width,dash); // 绘图线段 for(let i=0;i<points.length;i++){ if(i % 2 == 0){ //偶数项 let x = points[i]; let y = points[i+1]; this.ctx.lineTo(x,y); } } // 设定路径是否闭合 if (close == true){this.ctx.closePath();} // 填充和轮廓绘制 this.stroke_and_fill(border,fill,border_width) } } // 多边形 draw_polygon(points,border = "#000",fill = "#fff",border_width = 1,dash = null){ this.draw_polyline(points,true,border,fill,border_width,dash); } }
复制
// draw.js var canvas = new Canvas2D(200,200); canvas.draw_polyline([50,50,100,100,20,80])
复制
// draw.js var canvas = new Canvas2D(200,200); canvas.draw_polyline([50,50,100,100,20,80],false,"#000","#abc",3,[5])
复制
// draw.js var canvas = new Canvas2D(200,200); canvas.draw_polyline([50,50,100,100,20,80],true,"#000","#abc",3,[5])
复制
也可以直接使用draw_polygon
:
// draw.js var canvas = new Canvas2D(200,200); canvas.draw_polygon([50,50,100,100,20,80],"#000","#abc",3,[5])
复制
鼠标事件监听
可以使用以下两种方式监听鼠标事件:
canvas.canvas.onmousedown = function(e){ // 事件处理代码 } canvas.canvas.addEventListener("mousedown",function(e){ // 事件处理代码 })
复制
clientX和clientY
Event对象有clientX和clientY两个属性:
canvas.canvas.addEventListener("mousemove",function(e){ console.log(e.clientX,e.clientY); })
复制
上面的代码,只有鼠标再canvas的矩形范围内才会在控制台输出,但是打印的clientX和clientY是基于整个页面的,而不是canvas自己的坐标,所以我们还需要转化一下。
获取HTML元素的矩形
Element.getBoundingClientRect()
方法返回一个 DOMRect 对象,其提供了元素的大小及其相对于视口的位置。
是包含整个元素的最小矩形(包括 padding
和 border-width
)。该对象使用 left
、top
、right
、bottom
、x
、y
、width
和 height
这几个以像素为单位的只读属性描述整个矩形的位置和大小。除了 width
和 height
以外的属性是相对于视图窗口的左上角来计算的。
改成如下形式,就可以获取canvas内的局部坐标了:
var canvas = new Canvas2D(200,200); canvas.canvas.addEventListener("mousemove",function(e){ var rect = canvas.canvas.getBoundingClientRect(); console.log(e.clientX - rect.x,e.clientY - rect.y); })
复制
封装方法,简化代码:
// 2D canvas 辅助类 class Canvas2D{ constructor(width,height,p_node = document.body){...} // ================= 方法 ================= get_rect(){ // 获取矩形边界框 return this.canvas.getBoundingClientRect(); } to_local(x,y){ // 将全局坐标转换为canvas局部坐标 var rect = this.get_rect(); return [x - rect.x,y - rect.y]; } draw_circle(cx,cy,radius,border = "#000",fill = "#fff"){...} draw_polyline(border,fill,close = false,...positions){...} draw_polygon(border,fill,...positions){...} }
复制
测试代码改写为:
var canvas = new Canvas2D(200,200); canvas.canvas.addEventListener("mousemove",function(e){ console.log(canvas.to_local(e.clientX,e.clientY)); })
复制
画布清除
clearRect
// 2D canvas 辅助类 class Canvas2D{ constructor(width,height,p_node = document.body){...} // ================= 方法 ================= get_rect(){...} to_local(x,y){...} clear(){ // 清空画布 this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height) } draw_circle(cx,cy,radius,border = "#000",fill = "#fff"){...} draw_polyline(border,fill,close = false,...positions){...} draw_polygon(border,fill,...positions){...} }
复制
测试代码:
var canvas = new Canvas2D(200,200); canvas.draw_circle(100,100,50); canvas.canvas.addEventListener("mousedown",function(e){ canvas.clear(); })
复制
初始绘制一个圆,点击画布后清空。
绘制水平和垂直间隔线
var canvas = new Canvas2D(200,200); canvas.draw_hlines(6);
复制
var canvas = new Canvas2D(200,200); canvas.draw_hlines(6); canvas.draw_vlines(6);
复制
:::tips
Edge浏览器对canvas另存为和复制的支持
现在的Edge浏览器好像直接支持将canvas绘制的内容当做是类似img插入的图片,可以直接另存和复制。这相当于,你拥有了基于浏览器的图片创建能力。
:::
var canvas = new Canvas2D(200,200); canvas.draw_hlines(6,"#ccc"); canvas.draw_vlines(6,"#ccc");
复制
var canvas = new Canvas2D(200,200); canvas.draw_hlines(30,"#ccc"); canvas.draw_vlines(30,"#ccc"); canvas.draw_hlines(6,"#444"); canvas.draw_vlines(6,"#444");
复制
动态绘制辅助线
var canvas = new Canvas2D(200,200); draw(); //初始绘制 function draw(){ //主体绘制部分 canvas.draw_hlines(30,"#ccc"); canvas.draw_vlines(30,"#ccc"); canvas.draw_hlines(6,"#444"); canvas.draw_vlines(6,"#444"); } // 鼠标移动 canvas.canvas.addEventListener("mousemove",function(e){ canvas.clear(); draw(); canvas.draw_help_lines(e.clientX,e.clientY); })
复制
绘制图片
// draw.js // 创建并添加一个canvas let canvas = document.createElement("canvas"); canvas.width = 1200; canvas.height = 600; document.body.appendChild(canvas); let ctx = canvas.getContext("2d"); function draw(){ const img = new Image(); img.onload = function(){ ctx.drawImage(img,20,20,300,300) } img.src = "godot.png"; } draw();
复制
全屏
requestAnimationFrame
前端 - 浅析requestAnimationFrame的用法与优化 - 个人文章 - SegmentFault 思否
const animation = () => { // 绘制代码 requestAnimationFrame(animation); //结束后调用 } requestAnimationFrame(animation); // 第一次调用
复制
基于requestAnimationFrame的动画
// draw.js // 创建并添加一个canvas let canvas = document.createElement("canvas"); canvas.width = 200; canvas.height = 200; document.body.appendChild(canvas); let ctx = canvas.getContext("2d"); // 矩形的起点和宽高 let x= 0; let y= 0; let w= 50; let h= 50; var deltaX = 1; // 绘制函数 function draw(){ // 绘制逻辑 ctx.clearRect(0,0,canvas.width,canvas.height); // 清除上一帧绘制内容 if(x > canvas.width - w || x < 0){ deltaX *= -1; } x += deltaX; ctx.fillRect(x,y,w,h) // 下一帧 window.requestAnimationFrame(draw); // 调用绘制函数 } window.requestAnimationFrame(draw); // 调用绘制函数
复制
实现了动画:
save()和restore()
canvas理解:一看就懂的save和restore_canvas save restore-CSDN博客
save()
保存上下文的边线、填充以及线性变换状态,每次保存状态压入栈内restore()
弹出并恢复栈顶的上下文状态
// draw.js // 创建并添加一个canvas let canvas = document.createElement("canvas"); canvas.width = 200; canvas.height = 200; document.body.appendChild(canvas); let ctx = canvas.getContext("2d"); ctx.fillStyle = "red" ctx.fillRect(0,0,100,100); ctx.save(); // 保存状态 ctx.translate(50,50); ctx.fillStyle = "yellow" ctx.fillRect(0,0,100,100); ctx.restore() //恢复状态 ctx.fillStyle = "green" ctx.fillRect(0,0,50,50);
复制
ctx.translate(50,50);
是对画布的绘制位置进行了偏移,类似于GDScript中CanvasItem
的set_tramsform()
用法。
完整代码
// Canvas2D.js // 2D canvas 辅助类 class Canvas2D{ constructor(width,height,p_node = document.body){ this.canvas = document.createElement("canvas"); this.ctx = this.canvas.getContext("2d"); this.canvas.width = width; this.canvas.height = height; p_node.append(this.canvas); } set width(val){ this.canvas.width = val; } get width(){ return this.canvas.width; } set height(val){ this.canvas.height = val; } get height(){ return this.canvas.height; } // ================= 绘图样式 ================= // 设定绘图样式 set_draw_style(border = "#000",fill = "#fff",border_width = 1,dash = null){ let ctx = this.ctx; ctx.beginPath(); // 样式设定 ctx.strokeStyle = border; // 轮廓样式 ctx.lineWidth = border_width; // 轮廓线宽 ctx.fillStyle = fill; // 填充样式 if(dash != null && dash.length>0){ //虚线样式 ctx.setLineDash(dash); }else{ ctx.setLineDash([]); } } // 填充和轮廓绘制 stroke_and_fill(border = "#000",fill = "#fff",border_width = 1){ let ctx = this.ctx; if(border != null && border_width > 0){ ctx.stroke(); } if(fill != null){ ctx.fill(); } } // ================= 基础图形绘制 ================= // 圆 draw_circle(cx,cy,radius,border = "#000",fill = "#fff",border_width = 1,dash = null){ let ctx = this.ctx; // 设定绘图样式 this.set_draw_style(border,fill,border_width,dash); // 主体路径 ctx.arc(cx,cy,radius,0,Math.PI * 2); // 填充和轮廓绘制 this.stroke_and_fill(border,fill,border_width) } // 直线和折线 draw_polyline(points,close = false,border = "#000",fill = "#fff",border_width = 1,dash = null){ let ctx = this.ctx; if(points.length>3 && points.length % 2 == 0){ // 至少有2个点,而且的数量是2的整数倍 // 设定绘图样式 this.set_draw_style(border,fill,border_width,dash); // 绘图线段 for(let i=0;i<points.length;i++){ if(i % 2 == 0){ //偶数项 let x = points[i]; let y = points[i+1]; this.ctx.lineTo(x,y); } } // 设定路径是否闭合 if (close == true){this.ctx.closePath();} // 填充和轮廓绘制 this.stroke_and_fill(border,fill,border_width) } } // 多边形 draw_polygon(points,border = "#000",fill = "#fff",border_width = 1,dash = null){ this.draw_polyline(points,true,border,fill,border_width,dash); } // ================= 矩形与坐标 ================= get_rect(){ // 获取矩形边界框 return this.canvas.getBoundingClientRect(); } full_screen(){ // 全屏 - 设置canvas的尺寸为页面尺寸 this.width = window.innerWidth; this.height = window.innerHeight; } to_local(x,y){ // 将全局坐标转换为canvas局部坐标 var rect = this.get_rect(); return [x - rect.x,y - rect.y]; } // ================= 矩形与坐标 ================= clear(){ // 清空画布 this.ctx.clearRect(0,0,this.width,this.height) } // ================= 网格线 ================= draw_hlines(num,border = "#000",border_width = 1,dash = null){ // 绘制水平间隔线 const dh = this.height / num; const w = this.width; for(let i =0;i<num+1;i++){ this.draw_polyline([0,dh * i,w,dh * i],false,border,null,border_width,dash) } } draw_vlines(num,border = "#000",border_width = 1,dash = null){ // 绘制垂直间隔线 const dw = this.width / num; const h = this.height; for(let i =0;i<num+1;i++){ this.draw_polyline([dw * i,0,dw * i,h],false,border,null,border_width,dash) } } // ================= 辅助线 ================= draw_help_lines(x,y,border = "orange",border_width = 1,dash = null){ // 绘制水平和垂直辅助线 const h = this.height; const w = this.width; const local = this.to_local(x,y); this.draw_polyline([0,local[1],w,local[1]],false,border,null,border_width,dash) this.draw_polyline([local[0],0,local[0],h],false,border,null,border_width,dash) } }
复制