Day5 - 2D渲染环境基础篇 II - 成为Canvas Ninja ~ 理解2D渲染的精髓

何谓路径?

要介绍路径绘图相关的api之前,必须要先理解什麽叫做『路径』。
有学过电脑绘图软件,例如Adobe Photoshop, Adobe Illustrator的同学可能对『路径』这个词相当的熟悉,同时也可能可以更快速掌握2D渲染环境路径绘图的概念,但是考量到大多数人都没有美术学经历背景,所以这边还是简单做点说明~

路径是使用绘图工具建立的任意形状的曲线,用它可勾勒出物体的轮廓,所以也称之为轮廓线。 为了满足绘图的需要,路径又分为开放路径和封闭路径。 --维基百科

如果要白话一点的解释『路径(Path)』这个概念,可以想像成他是由一条透明的曲线所圈出来的一块(非)开放区域,在canvas中我们可以利用(接下来会提到的)上色填充相关api为已经成形的路径设定填充色(fill)/边框色(stroke)。

有学过SVG相关知识的同学应该马上就会发现这其实就跟SVG的Path 属性是一样的东西~没错,路径(Path)其实是计算机绘图领域的概念,并不是Canvas独有的。

(图片说明:在上图我们可以看到我们必须要先有一个叶子形状的Path,然後接着才可以对这个Path施加Fill和Stroke)

接下来我们要藉由实作的方式来加速学习api的使用方式,藉由实际操作API来画一条线/一个圆/一个不规则形状来加深对API的认识。

在开始之前,有一个特别需要注意的地方,那就是『绘制路径』这个行为过程其实有点类似於用笔尖在纸面上作画。
这个『笔尖』会是一个实际存在的座标(但是你看不到),打个比方:假设我们现在画了一段由A点画向B点的路径,那麽『笔尖』最後也会停在B上面。
这时候要注意,如果没有利用API去移动笔尖,而是直接在别的地方画了一个新的形状,那麽先被画出来的形状和後被画出来的形状就会产生多余的连线。

来画一条线看看吧!

很遗憾的, IT邦似乎没有提供embed codepen iframe 的功能,所以我只能把源码写在这里了


function draw(){
  let cvs = document.querySelector('canvas');
  let ctx = cvs.getContext('2d');
  ctx.beginPath(); //宣告开始绘制路径
  // 把笔尖的座标移动到50,50
  ctx.moveTo(50,50);
  // 从当前笔尖座标开始画Path,一路画到200,50
  ctx.lineTo(200,50);
  // 设定边框颜色
  ctx.strokeStyle="#fff";
  // 赋予框线
  ctx.stroke();
  ctx.closePath(); //宣告结束绘制路径, 这时『路径』就不复存在,只会留下stroke 所带来的颜色
}

(()=>{
  draw();
})()

codepen连结:
https://codepen.io/mizok_contest/pen/XWgeKoJ

起手式!

任何的路径在开始画之前,最好都要先使用ctx.beginPath()来宣告『嘿,我要开始画路径罗』;
然後在结束路径绘制时,也最好使用ctx.closePath() 来宣告结束路径的绘制(如果有手动把路径连回原点,或用别的方法把路径闭合,那也可以不用closePath);

一般来说如果不宣告结束,那麽路径就会一直存续下去,这样就没有办法画出个别独立的图形(例如个别独立颜色不同的方块)

另外一提,ctx.fill() (填充颜色的api) 本身自带closePath的效果,所以如果先执行了fill(),则可以不用额外宣告结束路径绘制。

接着来画一个圆!

// API 用法
void ctx.arc(x, y, radius, startAngle, endAngle [, counterclockwise]);
// x: 圆心X座标
// y: 圆心Y座标
// radius : 半径
// startAngle: 起始角度=> 记得是顺时针为正喔(而且必须要是径度量)
// endEngle: 结束角度=> 记得是顺时针为正喔(而且必须要是径度量)
// counterClockwise : 是否以逆时针方向作画

这边我提出了一个错误的范例,和一个正确的范例,让大家可以参考一下错误的原因和正确的写法。

    function drawCircle1(){
  let cvs = document.querySelector('canvas');
  let ctx = cvs.getContext('2d');
  ctx.beginPath();
  // 把笔尖的座标移动到50,50
  ctx.moveTo(50,50);
  // 从当前笔尖座标为圆心画一个半径50的圆形Path
  ctx.arc(50,50,50,0,Math.PI*2,false)
  // 设定边框颜色
  ctx.strokeStyle="#fff";
  // 赋予框线
  ctx.stroke();
  ctx.closePath();
  
  // 这边会发现圆形跟预期的不太一样,多了一条线,那就是因为我们提到的笔尖没有移动而产生的问题
}

function drawCircle2(){
  let cvs = document.querySelector('canvas');
  let ctx = cvs.getContext('2d');
  ctx.beginPath();
  // 这边我们把笔尖改成移动到实际画圆的起始座标
  // 把笔尖的座标移动到250,200
  ctx.moveTo(250,200);
  // 从当前笔尖座标为圆心画一个半径50的圆形Path
  ctx.arc(200,200,50,0,Math.PI*2,false)
  // 设定边框颜色
  ctx.strokeStyle="#fff";
  // 赋予框线
  ctx.stroke();
  ctx.closePath();
  
  // 这边会发现这样就正常了
}

(()=>{
  drawCircle1();
  drawCircle2();
})()

codepen连结:
https://codepen.io/mizok_contest/pen/RwgLGPQ

画一个不规则形状!

这边我们利用画二次曲线的API来画一个由三条曲线构成的形状,接着填充并且赋予框线。
这边可以稍微理解一下Canvas 的API ~ ctx.curveTo是怎麽定义二次曲线的参数需求。
简单来说这个api把一段二次曲线看作是一个由三个点所构成的曲线,三个点分别是:

  • 开始画线时笔尖的座标(第一端点)
  • 曲线结束的端点座标(第二端点)
  • 两个端点沿着曲线拉出的切线所形成的交点,是为『控制点 cp』

void ctx.quadraticCurveTo(cpx, cpy, x, y);
// cpx: 曲线控制点X座标
// cpy: 曲线控制点Y座标
// x: 曲线结束点X座标
// y: 曲线结束点Y座标
function drawBlob(){
  let cvs = document.querySelector('canvas');
  let ctx = cvs.getContext('2d');
  ctx.beginPath();
  // 把笔尖的座标移动到200,200
  ctx.moveTo(200,200);
  // 从当前笔尖座标画一条二次曲线到250,250(可以想像成抛物线),这时画完後笔尖座标会移动到250,250
  ctx.quadraticCurveTo(500,300,250,250);
   // 从当前笔尖座标再画一条二次曲线到100,100(可以想像成抛物线),这时画完後笔尖座标会移动到100,100
  ctx.quadraticCurveTo(100,100,30,200);
  
  // 最後再连回起始点200,200
  ctx.quadraticCurveTo(30,30,200,200);
  
  // 设定边框颜色
  ctx.strokeStyle="#fff";
  // 赋予框线
  ctx.stroke()
  
  // 设定边框颜色
  ctx.fillStyle="red";
  // 赋予框线
  ctx.fill(); // 事实上fill会自带closePath的效果
  ctx.closePath(); // 也就是说这一行可以不写也没差
}

(()=>{
  drawBlob();

})()

codepen连结:
https://codepen.io/mizok_contest/pen/ExXwNZL

Canvas Property的纪录(save)与复原(restore)

我们在前面的三个范例都有去调整过渲染环境当前的fillStyle和 strokeStyle 来改变填充色和边框的颜色。
(有电脑绘图经验的同学可能很快的就注意到了--这两个东西其实就是Illustrator的前景色和背景色吧!)
要知道,Canvas的Property在同一时间底下是只有一个唯一值的,也就是说填充色在同一瞬间只能被指定一个hex作为类似全域变数的概念,
所以如果今天有一个需求,要先画出一条红色的线,接着再画出一条蓝色的线,流程便会是:

  • 把fillstyle 设定成红色
  • 画第一条线
  • 把fillstyle 设定成蓝色
  • 画第二条线

虽然这样的场景很单纯看起来没什麽,但是如果今天到了很复杂的状况,例如初始颜色是透过random函数随机决定的,而绘制过程中突然有需求回归原本random到的那个颜色,那就会需要有能够复原Property的需求。

上述的场景虽然可以透过把字串值存取道临时变数来达成,但是别忘了,Canvas 的Property 远远不止strokeStyle 一个,如果任何一个Property都要存一个变数,想必程序码会变得很乱。

这时我们就可以透过Canvas 的原生API,也就是ctx.save()与 ctx.restore() (存档与读档) 来达成上述需求。

这边我提出了一个范例,范例中我先random 了一个hex色码来作为初始颜色,画一条线,接着把颜色改为蓝色再画一条线,最後我则是回归原本ramdom到的颜色画第三条线。

function randomColor(){
  const color = '#' +  Math.floor(Math.random()*16777215).toString(16);
  return color;
}

function drawLines(){
  let cvs = document.querySelector('canvas');
  let ctx = cvs.getContext('2d');
  // 把笔尖的座标移动到200,200
  ctx.beginPath();
  ctx.moveTo(200,200);
  // 拉线到300,200
  ctx.lineTo(300,200)
  ctx.closePath();
  //画一条随机颜色的线
  
  // 设定边框颜色
  ctx.strokeStyle=randomColor();
  ctx.save(); //存档
  // 赋予框线
  ctx.stroke()
  
  // 把笔尖的座标移动到200,220
  ctx.beginPath();
  ctx.moveTo(200,220);
  // 拉线到300,220
  ctx.lineTo(300,220)
  ctx.closePath();
  // 设定边框颜色
  ctx.strokeStyle="blue";
  // 赋予框线
  ctx.stroke()
  
  
  // 把笔尖的座标移动到200,240
  ctx.beginPath();
  ctx.moveTo(200,240);
  // 拉线到300,220
  ctx.lineTo(300,240)
  ctx.closePath();
  // 设定边框颜色
  ctx.restore(); //读档
  // 赋予框线
  ctx.stroke()
 
}

(()=>{
  drawLines();

})()

codepen连结:
https://codepen.io/mizok_contest/pen/PojJWaE

路径绘制/上色 - 小结

实际上,canvas 关於绘制路径的api还远不止上述提到的这几种。
例如曲线还有 ctx.bezierCurveTo()(贝兹曲线), 设定边框粗细可以用 ctx.lineWidth, 设定端点类型可以用ctx.lineJoin...,etc

这些api/property 如果要在文章中一一介绍其实多少会变得有点流水帐,所以我比较倾向让大家自己去搜寻自己需要的api

推荐在查询api 的时候可以多使用MDN~ MDN 会是学习Canvas基础的好帮手。

https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D


<<:  [Day 5] HTTP Method

>>:  Day06 WebRTC 中的 Signaling Server

【从零开始的Swift开发心路历程-Day30】认识GCD多执行绪Part3(完)

【从零开始的Swift开发心路历程-Day30】认识GCD多执行绪Part3(完) 昨天我们用程序码...

[Day 07] 特徵图想让人分群 ~模型们的迁移学习战~ 第二季 (k-means 实作篇)

前言 昨天我们使用预训练模型EfficientNet去提取一张表情的高阶特徵图(1280张特徵图),...

第24天 - 文件审核系统(2)_列印呈现的部分

闲聊一下: 这个系统是我学习PHP的成果(因为专题要做这个,我负责PHP相关的部分,其他人负责架SE...

Day 11 打包 python 程序-3

接续上一篇 我们再把 keras.engine.base_layer_v1 加入到 hiddenim...

R语言-4-函数

每一个动作都是函数 语法 A::install.packages("aa") ...