本文不允許任何形式的轉載!

閱讀提示

本系列文章不適合以下人群閱讀,如果你無意點開此文,請對號入座,避免浪費你寶貴的時間。

  • 想要學習利用遊戲引擎開發遊戲的朋友。本文不會涉及任何第三方遊戲引擎。
  • 不具備面向對象編程經驗的朋友。本文的語言主要是Javascript(ECMA 2016),如果你不具備JS編程經驗倒也無妨,但沒有面向對象程序語言經驗就不好辦了。
  • 想要直接下載實例代碼的朋友。抱歉,我都用嘴說,基本上沒有示例代碼。

上期作業

如何用moveTo,lineTo,beginPath和closePath去實現arc介面呢?其實不難,只要我們能計算出弧形上的點的位置,然後一個一個連接他們就好了,代碼如下:

function arc(x, y, r, startAngel, endAngel) {
if (startAngel == endAngel) return; // 如果弧度是沒變化的,畫尼瑪

let plusRadian = 0.01; // 我們固定一個增量為0.01
if (startAngel > endAngel) { // 如果弧度遞減繪製點的,那plusRadian為負數
plusRadian *= -1;
}
let startPoint = getPointOnArc(x, y, r, startAngel);
ctx.moveTo(startPoint.x, startPoint.y);

for (let radian = startAngel + plusRadian; condition(radian); radian += plusRadian) {
let nextPoint = getPointOnArc(x, y, r, radian);
ctx.lineTo(nextPoint.x, nextPoint.y);
// 如果增量轉了一圈,就退出沒必要再繪製了
if (Math.abs(radian) >= 2 * Math.PI) {
break;
}
}

function getPointOnArc(x, y, r, radian) {
let x1 = x + r * Math.cos(radian);
let y1 = y + r * Math.sin(radian);
return {x: x1, y: y1}
}
function condition(radian) {
if (plusRadian > 0) {
return radian < endAngel;
}
if (plusRadian < 0) {
return radian > endAngel;
}
}
}

這樣寫出來是可以實現的,但是需要的點都是固定的,設想一下,一個半徑只有1的圓,我們真的不需要這麼多個點來圍城一個弧形,幾個就夠了;反之如果半徑非常大,那僅僅增加0.01個弧度是無法繪製出一個弧線的,所以增量不能固定,需要配合半徑進行計算,如下圖所示:

那上面那個程序的plusRadian就可以改成:

let plusRadian = Math.asin(0.5 / r)*2; // 這次我們將這個增量通過半徑算出來

這樣一來,就算完成了arc方法了(如果有bug自行修改)。那真就完成了么?不是的,我們在本文最後再說一下這個問題。

Context狀態

所謂狀態就好像我們畫畫的時候所用的筆以及紙的不同狀態,例如,我要畫一個太陽,起初拿了一根紅色筆畫出太陽的圓圈,然後我換成了黃色的筆來塗畫圓形內部,那麼我可以認為目前我畫了這個太陽用到了兩種不同狀態的筆:紅色很黃色。

展開來想,筆的狀態不僅僅是顏色的不同,還有筆芯的粗細啦等等。

而什麼是紙的狀態呢。我們畫畫的時候,經常是手壓住紙,手是要在紙上來回找位置繪製,但也有時會把紙挪動一下位置便於我們畫圖,例如我要在剛才的太陽下面畫一座小山,那我會在畫好太陽後把紙往上移動,我的手就懶得挪動太遠去畫那座山了。

在CanvasRenderingContext2D中(下面統稱ctx),有一種東西叫做狀態,就是來實現剛才我說的那些,比如畫筆顏色,可以用strokeStyle和fillStyle進行設置,筆芯的粗細可以用lineWidth來設置,移動紙張可以用translate來設置,等等。

現在來說說ctx里的狀態有哪些。如果我們按照剛才所說的,把ctx的常用狀態看成筆和紙,大致可以分成:

畫筆狀態:

  • 顏色 : fillStyle, strokeStyle
  • 透明度 :globalAlpha
  • 線條寬度:lineWidth
  • .....

坐標變換狀態:

  • 移動位置:translate
  • 旋轉:rotate
  • 縮放:scale

畫筆狀態很好理解,上一節就用到過,我們現在舉個例子,快速搞清楚上面說的位置變化狀態。

Context的translate

現在我要畫一組圖形,是兩個寬高為50像素正方形的方塊,方塊2的左上角在方塊1的中心,代碼可以這麼寫:

let ctx = main.getContext(2d);
ctx.fillStyle = black;

drawRect(0,0,50,50); // 左上角坐標在0,0處,寬高為50的正方形
// 左上角坐標在上個正方的中心,也就是(25,25)的一個正方形
drawRect(25,25,50,50);

function drawRect(x, y, width, height) {
ctx.beginPath();
ctx.rect(x, y, width, height);
ctx.closePath();
ctx.fill()
}

上面這個段代碼,我們可以想像成:用筆在0,0點畫了一個正方形,然後我拿起筆移動到這個正方形的中心位置再畫一個正方形。如果我手不動,而是紙動呢,結合上面所提到的ctx的translate方法,代碼改為:

let ctx = main.getContext(2d);
ctx.fillStyle = black;

drawRect(0,0,50,50); // 在左上角(0,0)繪製一個50x50的正方形
ctx.translate(25,25); // 變換繪製坐標(移動紙張)
drawRect(0,0,50,50); // 在新的坐標系的(0,0)繪製一個50x50的正方形

function drawRect(x, y, width, height) {
ctx.beginPath();
ctx.rect(x, y, width, height);
ctx.closePath();
ctx.fill()
}

這兩段代碼得到的結果是一樣的,但是理解起來就不一樣了。第一段代碼是我們在不同的坐標點繪製正方形,而第二段代碼我們繪製的正方形左上角坐標都是(0,0),只是在繪製第二個的時候,ctx的坐標系發生了變化,就好像我在畫畫,剛畫好一個正方形,然後我把紙挪動了,但是我的手並沒有動,繼續在剛才繪製正方形的地方再畫一個。 通常來講,計算機二維圖形的坐標系是以左上角作為原點,x軸往右遞增,y軸往下遞增。ctx的坐標系也是如此,在一開始,ctx的坐標系也是以canvas的左上角作為原點的,一旦我們調用了ctx.translate方法,就能更改這個坐標系的原點(想像一下我們挪動紙張畫畫的情景)。

Context的rotate

這個很重要,希望能認真看

先看代碼:

let ctx = main.getContext(2d);
ctx.fillStyle = black;

ctx.rotate(45*Math.PI/180); // 旋轉45度
drawRect(0,0,50,50);

function drawRect(x, y, width, height) {
ctx.beginPath();
ctx.rect(x, y, width, height);
ctx.closePath();
ctx.fill()
}

我們在繪製剛才第一個方塊的之前調用了rotate方法,那得到的結果是這樣的:

我們看到,整個方塊發生了旋轉,但因為我們的canvas大小原因只顯示了一半。

我們知道,旋轉一個物體是需要有幾個前提,一個是該物體要基於哪個點進行旋轉,二是旋轉的弧度以及方向,上述的這次旋轉是以哪個點轉的呢?旋轉方向又是什麼?我們畫個圖就能理解了。

ctx里,所有旋轉都是基於當前畫布的原點,我們上述代碼中,rotate 45度,就是基於畫布的原點旋轉的,而且該原點並沒有發生變化,依舊是【0,0】。

而旋轉方向的規則是這樣的:以x軸往右作為方向,如果旋轉角度是大於0的,則順時針旋轉;如果旋轉角度小於0,則逆時針旋轉。

旋轉方向還好理解,也好更改,那旋轉點怎麼改呢?就是我們上一小節提到的translate(退回去看看)。例如,我們在旋轉之前將原點改到(25,25)會怎樣呢

let ctx = main.getContext(2d);
ctx.fillStyle = black;

ctx.translate(25,25);
ctx.rotate(45*Math.PI/180);
drawRect(0,0,50,50);

function drawRect(x, y, width, height) {
ctx.beginPath();
ctx.rect(x, y, width, height);
ctx.closePath();
ctx.fill()
}

我們可以看到,這個方塊旋轉還是那樣旋轉的,只是位置改了,如圖所示:

好了,現在知道怎麼改旋轉原點和怎麼進行旋轉了,那我們考慮一下這個case: 我想讓方塊基於它的中心點旋轉45度,怎麼辦。先看代碼:

let ctx = main.getContext(2d);
ctx.fillStyle = black;

ctx.translate(25,25);
ctx.rotate(45*Math.PI/180);
ctx.translate(-25,-25);
drawRect(0,0,50,50);

function drawRect(x, y, width, height) {
ctx.beginPath();
ctx.rect(x, y, width, height);
ctx.closePath();
ctx.fill()
}

可以看到,ctx的位置變化是這樣的:

ctx.translate(25,25);
ctx.rotate(45*Math.PI/180);
ctx.translate(-25,-25);

我在紙上畫畫,看看畫布(紙張)的位置到底在發生什麼變化:

ctx.translate(25,25);

?

接著:

ctx.rotate(45*Math.PI/180)

最後,我們調用了

ctx.translate(-25,-25);

紅色框就是進行了三次變換後的畫布的最後位置,那我們在上面要是畫剛才的那個方塊

drawRect(0,0,50,50);

那這個方塊就正好是基於(25,25)點進行了一次旋轉。

如果遇到ctx的位置變換,實在不明白就在腦子裡想像出一張畫布,然後每次變換後想一下它所在的位置,就好像我們的紙一樣,我們在不停擺弄著它以便於我們繪製。

Context的scale

scale(縮放)是最好理解的,無非就是將坐標系拉伸或者縮小嘛。跟旋轉一樣的,縮放也是基於原點的哦。

比如:

let ctx = main.getContext(2d);
ctx.fillStyle = red;
ctx.globalAlpha = 0.5;
drawRect(0,0,50,50);
ctx.scale(1.5,1.5);
ctx.fillStyle = black;
drawRect(0,0,50,50);

function drawRect(x, y, width, height) {
ctx.beginPath();
ctx.rect(x, y, width, height);
ctx.closePath();
ctx.fill()
}

不用看結果,想都能想出來,有兩個左上角坐標是0,0的方塊,第二個比第一個大1.5倍,因為我們把坐標系放大了1.5倍。我這裡用到了globalAlpha,讓繪製的圖形透明,這樣好辨認

scale無非就是讓坐標系進行縮放嘛,對不對。但是,一定要注意!一定要注意!一定要注意!scale並不是單純的拉伸了長寬,而是讓坐標系(看清楚是坐標系)整體發生了伸縮變化。啥意思啊,就是說如果我調用一次scale(2,2), 不是單純理解為畫布被放大了2倍,連坐標都放大了兩倍(這麼說有點不妥,但是好理解)。

上面那個case我們的方塊坐標都是0,0,看不出來什麼不一樣,但如果我們把坐標改成50,50後會成這樣:

let ctx = main.getContext(2d);
ctx.fillStyle = red;
ctx.globalAlpha = 0.5;
drawRect(50,50,50,50);
ctx.scale(1.5,1.5);
ctx.fillStyle = black;
drawRect(50,50,50,50);

function drawRect(x, y, width, height) {
ctx.beginPath();
ctx.rect(x, y, width, height);
ctx.closePath();
ctx.fill()
}

看到了嗎,黑色方塊的坐標並沒有和紅色方塊的重合,就是因為整個坐標系都被放大了,在放大後的(50,50)和在放大之前的(50,50)並不一樣。可以這麼理解,原坐標也被放大了2倍,現在的50,50相當於以前的100,100

我先講這麼多,在後續會有更詳細的說明。

Context的狀態棧

狀態這個東西不可能一直就這樣變化下去,有時候我們只想局部發生變化,比如我畫了一個黑色的方塊,接著我想畫一個旋轉了45度的紅色方塊,最後我想在第一次繪製的黑色方塊旁100像素位置再畫一個黑色方塊。

如果根據我們上面的代碼,就這麼寫:

let ctx = main.getContext(2d);
ctx.fillStyle = black;
drawRect(50,50,50,50);
ctx.fillStyle = red;
ctx.rotate(45*Math.PI/180); // 順時針旋轉45
drawRect(50,50,50,50);

ctx.rotate(-45*Math.PI/180); // 逆時針旋轉45,即回到剛才黑色方塊的狀態
ctx.fillStyle = black;
ctx.translate(100,0);
drawRect(50,50,50,50);

function drawRect(x, y, width, height) {
ctx.beginPath();
ctx.rect(x, y, width, height);
ctx.closePath();
ctx.fill()
}

看,在畫完第二次後,為了讓畫布回到當初的狀態,我不得不反向旋轉一次。很sb吧。

ctx提供了兩個方法,一個叫save,一個叫restore,save是保存當前狀態,restore是恢復之前狀態。

啥意思,就是說,我一旦調用save,那當前ctx的所有狀態都會被保存起來,我可以任意修改,當我調用restore的話,就會把剛才保存的狀態恢復。

這個是不是就是一個棧?我們模擬一下save和resotre,是這樣的:

function save(){
stateStack.push(currentState.clone());
}

function restore(){
currentState = stateStack.pop();
}

get currentState(){
return stateStack[stateStack.length - 1]
}

一旦調用save,那ctx就會把當前狀態克隆出來,壓到棧中;那我們在繪製後續圖形的時候,當前的狀態隨你怎麼改都無所謂,反正被保存起來了,當我們調用restore,那當前的狀態就恢復成了之前保存的狀態。

所以,我剛才那段sb代碼可以改成這樣:

let ctx = main.getContext(2d);
ctx.fillStyle = black;
drawRect(50,50,50,50);

ctx.save();// 保存當前的狀態
ctx.fillStyle = red;
ctx.rotate(45*Math.PI/180); // 順時針旋轉45
drawRect(50,50,50,50);

ctx.restore(); // 恢復之前狀態(就是調用save前的狀態)
ctx.translate(100,0);
drawRect(50,50,50,50);

切記,save和restore一般都是成對出現了,比如

ctx.save()

。。。// 做一些繪製操作

ctx.save();

。。。// 做另一些繪製操作

ctx.restore();

ctx.restore();

。。。// 再做一些操作

這樣的話就不會造成一些莫名其妙的錯誤發生,你可以認為save和resotre相當於一段代碼的{和},在括弧內做你的操作,隨便改狀態,一旦出了括弧,括弧內你做的更改都沒了。

狀態棧很簡單,知道Stack是什麼就好理解它,我就不廢話了。

小結

說到context的狀態,實際上我主要還是講了坐標變換而已,畢竟這個比起修改顏色啊,透明度要難一點,如果我把這些坐標變換的過程改到矩陣計算來說的話,就要更容易理解,我會在後期講到webgl的時候再提及坐標的矩陣變換。

作業

上面我有個case:讓某個方塊根據它的中心點進行旋轉,我也給出了代碼,這個是有現實意義的,我們在移動端的旋轉某圖片的時候都是按照其中心旋轉的。 那麼,我要讓某個方塊根據它的中心進行伸縮呢?代碼該怎麼寫?

推薦閱讀:

查看原文 >>
相关文章