iT邦幫忙

2021 iThome 鐵人賽

DAY 5
0
Modern Web

成為Canvas Ninja ! ~ 理解2D渲染的精髓系列 第 5

Day5 - 2D渲染環境基礎篇 II - 成為Canvas Ninja ~ 理解2D渲染的精髓

  • 分享至 

  • xImage
  •  

何謂路徑?

要介紹路徑繪圖相關的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


上一篇
Day4 - 2D渲染環境基礎篇 I - 成為Canvas Ninja ~ 理解2D渲染的精髓
下一篇
Day6 - 2D渲染環境基礎篇 II [同場加映 - 非零纏繞與奇偶規則] - 成為Canvas Ninja ~ 理解2D渲染的精髓
系列文
成為Canvas Ninja ! ~ 理解2D渲染的精髓31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
jerrythepotato
iT邦新手 3 級 ‧ 2021-10-13 23:42:59

我們理解好像有一點不同,實作上的經驗closePath並不是真的關閉路徑,後續還是可以繼續繪製其他路徑,beginPath才是那個能夠開始繪製一段新路徑的方法。

並且我查了一下MDN求證,據其描述,closePath方法也僅是畫一條直線,並無其他特別的涵義

也偷偷分享一個自己文章的連結XD,裏頭我有解釋canvas繪圖的方法
「closePath實務上較少使用,也容易被混淆」

Mizok iT邦新手 3 級 ‧ 2021-10-14 01:05:08 檢舉

應該說是連到原點之後他的路徑就會關閉了,確實如果有手動去把線連到原點,那這個路徑就會被關掉,而clothPath也不會有其他作用~

我會再修正一下描述 :D

我要留言

立即登入留言