WFX-Maniac

0%

我們在上一次講到用數理觀點來觀察反射行為的諸多細節,而這篇文則是要講解斜向拋射
不過因為斜向拋射的概念其實不是挺複雜,所以為了不要浪費篇幅,我也會先把一些程式的實作面先在這一篇做一個簡單的引導~

斜向拋射

斜向拋射這個名詞,我想應該對很多人來說都有很深的印象。(想當年剛成為理組小菜雞的我在物理課上到這一段的時候真的有三觀被刷新的感覺XD)

作為物理模擬開場的第一進程,當然就要來講一下最經典的物理模擬案例:『彈跳球』~
其實很多國外的Canvas特效教程都會把這一篇當成第一個介紹案例,比方說

這邊推薦一下 Apress Physics for JavaScript Games Animation and Simulations 這本書,因為在學習物理模擬的路上這本書給了我不少幫助XD~

在這個案例中我們除了會介紹彈跳球的案例,還會介紹一些關於這個案例的基礎物理常識,最後還會帶到一些更進階的物理模擬實作。

在一開始我們還不會馬上的帶到程式源碼,而是要先來討論高中數理的向量反射斜向拋射,由於我們在這個案例中會持續用到的三個基礎概念,所以我打算在一開始就講清楚物理模擬在這三部分的相關概念。

我們在這篇文章中會先討論到Canvas向量類的建立,就讓我們接著開始吧~

向量是什麼樣的概念?

我們其實在前面的文章有提過向量,向量指的是一種從座標A移動到座標B的附帶方向的移動量,從數學的角度上來看,假設今天有一個質點即將從(1,2)移動到(2,4),則我們可說這個質點被附加了一個(1,2)的移動向量。

向量如果要轉變成純量,那麼就必須要取該向量X,Y值的平方和,然後再開根號(畢氏定理),以我們剛剛提到的(1,2),他的純量就是√5(也就是該質點一共移動了√5的距離長度)。

向量再轉變成純量的過程中會丟失他的方向屬性,而變成單純的量值,所以如果今天換成另外一個案例,假設我們只知道移動的距離是√5而不知道這個移動的起始點和結束點; 想要把√5這個距離轉變成向量(也就是要知道水平和垂直移動的距離),那我們就必須要先獲知該純量的方向(也就是下圖中的角度θ),然後用三角函數來把√5轉變成1(水平移動量)和2(垂直移動量)。

img

1
(cosθ * √5, sinθ * √5) = (1,2)

除了向量變純量, 純量變向量的運算以外,向量之間有其他類型的運算,像是:

  • 相加/相減

以下面這張圖為例,我們可以可以把紫色向量看作是向量a(紅色向量)和向量b(藍色向量)的和。
所以反過來也可以推導紫色向量 - 向量a = 向量b

  • 內積

內積是一個有趣的概念,求取兩個向量內積的方法如下:

1
2
假設向量a為(ax,ay),向量b則是(bx,by)
則向量a與向量b的內積是ax*bx+ay*by

內積的結果會是一個純量,他的幾何意義在於我們可以透過內積取得兩個向量的夾角。
透過內積取得夾角的公式如下:

img

一般來說,內積的值大於0,代表兩向量夾角低於90度
內積的值等於0,代表兩個向量互相垂直
內積的值小於0,代表兩個向量夾角介於90度到180度之間。

對公式推導有興趣的人可以看這邊

用javascript建立向量類(Vector Class)

在前端開發的環境下,我們其實可以利用ES6的class(當然也可以用ES5的構築式)去給向量建立一個獨立的類。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
class Vector2D {
constructor(x, y) {
this.x = x;
this.y = y;
}
/**
* 求純量值
*
* @returns
* @memberof Vector2D
*/
length() {
return Math.sqrt(this.lengthSquared());
}
/**
* 複製該向量
*
* @returns
* @memberof Vector2D
*/
clone() {
return new Vector2D(this.x, this.y);
}
/**
*倒轉該向量
*
* @memberof Vector2D
*/
negate() {
this.x = - this.x;
this.y = - this.y;
}

/**
* 把該向量轉變成單位向量
*
* @returns
* @memberof Vector2D
*/
normalize() {
let length = this.length(); if (length > 0) {
this.x /= length;
this.y /= length;
}
return this.length();
}

/**
* 回傳與某向量的向量和
*
* @param {*} vec
* @returns
* @memberof Vector2D
*/
add(vec) {
return new Vector2D(this.x + vec.x, this.y + vec.y);
}

/**
* 加上某向量
*
* @param {*} vec
* @memberof Vector2D
*/
incrementBy(vec) {
this.x += vec.x;
this.y += vec.y;
}

/**
*
* 回傳與某向量的向量差
* @param {*} vec
* @returns
* @memberof Vector2D
*/
subtract(vec) {
return new Vector2D(this.x - vec.x, this.y - vec.y);
}

/**
* 扣除某向量
*
* @param {*} vec
* @memberof Vector2D
*/
decrementBy(vec) {
this.x -= vec.x;
this.y -= vec.y;
}


/**
* 回傳擴增k倍後的向量
*
* @param {*} k
* @memberof Vector2D
*/
multiply(k) {
return new Vector2D(k * this.x, k * this.y);
}

/**
* 擴增該向量
*
* @param {*} k
* @memberof Vector2D
*/
scaleBy(k) {
this.x *= k; this.y *= k;
}


/**
* 求取該向量與其他向量的內積
*
* @param {*} vec
* @returns
* @memberof Vector2D
*/
dotProduct(vec) {
return this.x * vec.x + this.y * vec.y;
}

/**
* 求取此向量映射在某向量上的長度
*
* @param {*} vec
* @returns
* @memberof Vector2D
*/
projection(vec) {
const length = this.length();
const lengthVec = vec.length();
let proj;
if ((length == 0) || (lengthVec == 0)) {
proj = 0;
} else {
proj = (this.x * vec.x + this.y * vec.y) / lengthVec;
}
return proj;
}

/**
* 回傳一個新向量,新向量的方向會跟作為參數向量相同,但是量值上是作為此向量投射在參數向量上的長度
*
* @param {*} vec
* @returns
* @memberof Vector2D
*/
project(vec) {
return vec.para(this.projection(vec));
}


/**
* 根據傳入的u值來回傳一個u倍(或-u倍)的單位向量
*
* @param {*} vec
* @returns
* @memberof Vector2D
*/
para(u, positive = true) {

const length = this.length();
const vec = new Vector2D(this.x, this.y);
if (positive) {
vec.scaleBy(u / length);
} else {
vec.scaleBy(-u / length);
}
return vec;
}

/**
* 求取該向量與其他向量的夾角
*
* @param {*} vec
* @returns
* @memberof Vector2D
*/
static angleBetween = function (vec1, vec2) {
return Math.acos(vec1.dotProduct(vec2) / (vec1.length() * vec2.length()));
}

}

這邊我其實是參照Apress Physics for JavaScript Games Animation and Simulations, With HTML5 Canvas 上的寫法,改寫成ES6 Class,並刪除部分不常用到的方法。

我們在接下來的文章中會持續的用到由這邊建立好的向量類,所以各位同學可以看一下這個類裡面都有些什麼方法~

下一篇文我們將會講到如何在Canvas中實作反射(Reflection)行為,敬請期待~

繼上一篇我們講到向量類的建立,接著我們在這一篇文機會提到反射行為的模擬~
反射這種行為,在反射面鉛直水平時比較單純,所以大部分的Canvas彈跳球動畫教程都是針對這種狀況去做出來的。
但是在本篇的案例中,我們會提到在傾斜面上如何透過向量運算來計算反射的角度,同時也會講解Canvas物理模擬和現實物理最大的差異之一:『幀間誤差』,以及應對『幀間誤差』的解決辦法『歸位(Reposition)』。

反射會是整個案例最複雜的部分,所以我會盡可能地描述仔細一點~

從數理幾何觀點來看反射

img

如果說反射面只是單純的水平或垂直面,那麼反射的運算其實就很簡單,就是把垂直於反射面的向量分量倒轉而已。

但是如果今天反射面是傾斜的,那麼情況可就大不同了。

下圖中描繪著一個即將碰撞到傾斜面的球。(坐標系是使用Canvas的坐標系)

img

從上面這張圖,我們可以先定義兩個有關該球碰撞傾斜面當下瞬間的假設,也就是所謂的『碰撞偵測(Collision Detection)』

  • 球的中心到傾斜面距離低於球的半徑
  • 球的座標位置必須要介於傾斜面的兩個端點之間

接著我們再來看看這張圖。

img

假設我們一開始就知道球心的位置,和端點A&B的座標,這樣我們就可以藉由這些數據去取得球心到斜面的垂直距離。

算法大致上是這樣的:

1
2
3
4
5
// 已知球心座標和兩端點座標
// 所以這樣也就可以取得『向量AP』和『向量BP』還有反射面端點A到B形成的『向量AB』

let vectorAPP = vectorAP.project(vectorAB); //透過我們之前寫的向量類中的投射(project)方法來取得『向量APP』
let dist = vectorAP.substract(vectorAPP).length(); // 透過向量差值來取得d的長度

如果對高中數學還有印象的人其實也可以用點到線公式去求取距離,不過那就會是另外一套算法了

接下來我們要講講所謂的『幀間誤差』和『歸位(reposition)』。

我們在前面有提到過,canvas的動畫是藉由不斷地清除畫面後再重繪來達成的,所以說事實上球的動畫並不是一個完全連續的運動。

意思就是說假設1/60 秒的時候球的位置在0, 而2/60秒的時候球的位置在0.1,但是實際上1/60~2/60秒之間,球是沒有在運動的

這意味著球的運動其實是『格進』,所以說球在接近反射面的過程中,非常可能不會剛好有『球心與反射面距離 = 半徑』的狀況,多半情況下可能是球『插』進去了反射面裡面。

這個就是所謂的『幀間誤差』,而應對幀間誤差的方式之一就是使用『歸位(reposition)』:

假設球已經『插』進去反射面,則把它歸位回正好『球心與反射面距離 = 球半徑』的位置。

歸位後的位置,我們可以透過球心和反射面距離d,利用比例原理去計算出來。

img

圖片來自 Apress Physics for JavaScript Games Animation and Simulations, With HTML5 Canvas

事實上,幀間誤差還有一個可能導致更大影響的問題在,那就是在球本身具有加速度時的狀況。

在現實物理中,我們都知道若物體有受力,那麼它就必然的會產生加速度(牛頓第二定律),而這種行為如果要用Canvas模擬出來,就會受到幀間誤差的影響。
我們在前面有提到,幀間誤差的成因是因為canvas動畫的物體實際上是格進式的運動,以『球碰撞牆壁』這個案例來看,球有很大的機會在判斷撞上牆壁的那一刻,他會『』進去牆壁一小段距離,而這一小段距離實際上也會給球帶來比預期還要多的速度加成(原本在現實生活中頂多是加速到球心和牆壁距離為0而已),這種現象最終將會造成球每碰撞一次牆壁,就變得越快,而在碰撞多次的情況下可能就會造成動畫走鐘。

通常上述這種狀況的解法,我們可以為球加上一個值介於0~1之間的摩擦係數K,讓球在每次反彈的時候去把當下的速度向量做一部分的縮減,也就是變相地去抵銷幀間誤差所帶來的影響。

但是畢竟摩擦係數算是比較消極一點的做法,比較積極且精確的做法當然還是有,那就是利用第三加速度公式:

1
V^2=V0^2+2aS

由於我們在上面有提到,我們可以計算得出『歸位(reposition)』後的座標,那也就意味著我們可以計算出來歸為前一幀到歸位時所移動的長度,這樣也就可以進一步計算出歸位時的速度。

最後我們來到反射角度的計算,這部分就相對簡單一些。

img

反射後的速度向量,我們在這邊可以把他分割成沿著反射面方向的部分,和垂直於反射面的部分,而計算上則可以直接透過向量類的project方法來求得沿著反射面方向的部分,最後再用扣的去取得垂直於反射面的部分。

1
2
normalVelocity = dist.para(particle.velo2D.projection(dist));
tangentVelocity = ball.velocity.subtract(normalVelocity);

小結

這邊我們講解完了反射的運算方法,在接下來的部分我們會繼續帶到斜向拋射的部分,然後在逐漸地去把我們在文章中描述的邏輯構建到程式中~

『像素操作(Pixel Manipulation)』 顧名思義就是要去以單一像素為最小單位來進行操作,就像我們透過JS改變DOM結構所進行的『DOM操作』必須要先取得被操作元素一樣。

canvas 像素操作起手式

上面我們提到了要做像素操作,就必須先取得像素。

就像DOM操作一樣,在操作DOM的時候我們通常要先抓取(query)到目標元素,然後才可以接著做append/prepend/setAttribute之類的事情。

而像素操作的第一步就是要先取得canvas的像素數據(Image Data)。

1
2
3
4
5
let imageData = ctx.getImageData(sx, sy, sw, sh);
// sx: 想要取得的圖像區域的左上角x軸座標值
// sy: 想要取得的圖像區域的左上角y軸座標值
// sw: 想要取得的圖像區域的寬度
// sh: 想要取得的圖像區域的高度

何謂像素數據(Image Data)

我們在前面有提到過,canvas可以被視為一群像素的集合體,而每一個像素本身是由4個channel值所組成的。

『一張寬度100px, 高度100px的canvas,它實際上就是100*100 = 10000個像素的集合體,而同時在程序上我們則可以把它轉換成一個長度為100*100*4的陣列(也就是一共40000個channel值)。』 — 來自我們前面提過的內容

當我們用ctx.getImageData去取得完整一張canvas(sx,sy定為0, sw, sh定為canvas寬高)的imageData時,我們實際上會取得一個含有全部像素channel值的Uint8ClampedArray(8位元無符號整型固定陣列)

這邊我們透過console.log去檢驗一組由100px x 100px 大小canvas所提取的imageData

img

codepen連結: https://codepen.io/mizok_contest/pen/powKopj

簡單觀察一下首先可以發現, Uint8ClampedArray其實只是imageData的一部分(imageData.data),其餘還會有height/width等屬性,imageData本身具備獨立的型別,就像String/Array 那樣,他不只單純是個物件而已。

有關於ImageData這個型別相關的資訊可以看這邊

然後接著看看Uint8ClampedArray的部分,可以發現他確實就是由全部像素的channel值所組成;由於我們填入的顏色是紅色(255,0,0,1),所以channel值的分佈會是255,0,0,255這樣四個一組持續到結束的組合…..,這邊值得注意的一點是Uint8ClampedArray是以0到255來表示alpha channel的值,而不是0到1,那是因為8位元的關係(2的8次方是256, 而0~255剛好是256個數字)。

人類的眼睛大約只可分辨 1,000 萬種顏色,之所以channel值是用8位元陣列來表示,是因為256的3次方(rgb三原色)為16,777,216 , 這個數字恰好落在1000萬的level。

理解了ImageData的資料格式之後,接著可能就會有人問:

我們有沒有辦法從零自己建立一組新的ImageData?

Sure, 當然是可以的,而且方法還不只一種。

一般要自己create 新的ImageData,可以依靠:

  • 2D渲染環境底下的createImageData方法(ctx.createImageData)
  • ImageData class的 constructor (支援性低)

這兩種方法的最大差別就在於前者需要編譯環境下有2DContext存在,但是後者就是可以直接New一個物件出來(適用在部分非瀏覽器環境,另外IE不支援這方法)。

自己建立出來一個ImageData物件之後,接著可能就會有人再問:

那要怎麼把建立出來的ImageData 放到Canvas渲染出來?

這時候就該ctx.putImageData登場了~

1
2
3
4
5
6
7
8
void ctx.putImageData(imageData, dx, dy);
void ctx.putImageData(imageData, dx, dy, dirtyX, dirtyY, dirtyWidth, dirtyHeight);
// dx: 置放該ImageData渲染區的座標X值(置放在目標canvas上的位置)
// dy: 置放該ImageData渲染區的座標Y值(置放在目標canvas上的位置)
// dirtyX: 可以只渲染該ImageData的一部分, 這個值就是用來定義渲染區的起始座標X值(這個值是相對於該ImageData的0,0圓點而言)
// dirtyY: 可以只渲染該ImageData的一部分, 這個值就是用來定義渲染區的起始座標Y值(這個值是相對於該ImageData的0,0圓點而言)
// dirtyWidth: 可以只渲染該ImageData的一部分, 這個值就是用來定義渲染區的寬度
// dirtyHeight: 可以只渲染該ImageData的一部分, 這個值就是用來定義渲染區的高度

介紹完基本的ImageData API,我們接著來看一個蠻經典的像素操作案例~

經典的像素操作案例解析 - 拼字圖畫(Image To Ascii)

所謂的拼字圖畫就是像下圖這種,把圖像變成不同符號所形成的一幅圖

img

這邊我分成幾個主要步驟稍微描述一下拼字圖畫的程序邏輯

  • 用ctx.drawImage() 先把指定的圖片繪製到canvas上

  • 從繪製好圖像的canvas上取得imageData

  • 透過把imageData的每個像素點channel值總合取平均來將圖像轉為灰階

  • 根據灰階圖像的imageData來把不同的灰階值(例如050以@代替,51100以#代替)轉換為特定符號,然後把這些符號作為字串植入pre元素

接著是源碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// 取得圖像載入promise
function loadImage(src){
let img = new Image();
// 把resolve暴露給外部變數
let resolve;
let loadPromise = new Promise((res)=>{
resolve = res;
})
// 這一步cross-origin是因為我們的圖片是外部來源
// 如果沒有把外部來源設置為"Anonymous",drawImage方法會排除掉非本地來源的圖片資訊, 導致無法進行下一步繪圖
img.crossOrigin = "Anonymous";
img.onload = ()=>{
resolve(img);
}
img.src = src;

return loadPromise;
}

async function getImageDataFromImage(src,ratio = 0.5){
// 這邊有一個ratio參數是因為我讀取的圖片稍微有點大張
// 所以我補一個參數讓我可以自己決定要把圖片縮小多少倍率
let img = await loadImage(src);
let width = img.width * ratio;
let height = img.height * ratio;
// 把外部圖源繪製到架空的canvas上面然後取得imageData
let cvs = document.createElement('canvas');
let ctx = cvs.getContext('2d');
cvs.width = width;
cvs.height = height;
ctx.drawImage(img,0,0,width,height);
let imageData = ctx.getImageData(0,0,width,height);

return imageData;
}

async function turnImageDataIntoGrayscale(src){
let imageData = await getImageDataFromImage(src);
let data = imageData.data;
// 這邊這個loop的用意就在於把channel值依像素順序來執行程序
for(let i=0;i<data.length;i = i+4){
let r = data[i];
let g = data[i+1];
let b = data[i+2];
//取得rgb值得平均, 如此一來因為rgb都變成同一個數值
// 圖像就會變成灰階圖
let average = (r+g+b)/3
data[i] = data[i+1] = data[i+2] = average;
}
return imageData;
}

async function redrawAsASCII(src){
let grayscaleImageData = await turnImageDataIntoGrayscale(src);
// 取用的樣版字元
let glyphSource = "$@#*。";
let stringOutput = '';
for(let i = 0;i<grayscaleImageData.data.length;i = i+4){

let pixelIndex = Math.ceil(i/4);
// 從像素的次序來判斷該像素是否為右邊緣像素
let pixelIsRightRimPixel = (pixelIndex + 1) % grayscaleImageData.width === 0;
// 根據像素的灰階值, 用內插的方式來決定要使用哪一個樣版字元來代表該像素
let glyphIndex =
Math.floor(grayscaleImageData.data[i] / 255 * (glyphSource.length-1));

stringOutput+=glyphSource[glyphIndex];

if(pixelIsRightRimPixel){
// 如果是最右邊緣像素, 則另外補一個換行符號
stringOutput+='\n';
}
}
// 把字串填入pre tag
let text = document.querySelector('pre');
text.innerHTML = stringOutput;
}




(()=>{
redrawAsASCII('https://i.imgur.com/52TLlOk.png');
})()

codepen連結:https://codepen.io/mizok_contest/pen/vYZrXYP

小結

老實說我在挑選展示像素操作案例的時候猶豫了很久,最後還是決定要拿拼字圖畫來作為案例介紹。
主要是因為我覺得這個案例相較於其他的例子似乎更能讓人提起興趣(雖然對初學者來說可能有點小複雜)。

在上面這個案例中,其實可以學到很多的小技巧,包括:

  • canvas載入圖片的機制
  • imageData的邊緣像素處理
  • 將channel值依像素順序來執行迴圈程序

這些小技巧在這個系列文的中後段都會持續用到,所以建議可以仔細讀一下源碼裡面的註解~

這邊我們介紹的『拼字圖畫』其實還只是很基本的一種像素操作運用案例,像素操作真正被廣泛運用(同時也更複雜)的地方實際上在於影像處理(Image Processing)領域,我們將會在稍後的篇章再繼續提到這部分,敬請期待~。

OK, 我們終於來到了基礎篇最後的部分,也就是Canvas動畫~!(撒花)
在這個部分,我們會介紹:

  • canvas實作動畫的原理
  • 一個簡易動畫的實作案例

Canvas動畫原理

我們都知道,在現實生活中動畫(Animation)的原理其實是透過繪製很多不同但連貫的圖片,然後把這些圖片依序播放出來。
canvas實作動畫的原理其實也是一模一樣。

假設今天有一個60FPS(Frame Per Second, 意思就是每秒60幀)的動畫,如果我們要用Canvas來實作,那麼在動畫第一秒內的流程大約會是:

  • 在canvas上面繪製第一幀的樣子
  • 在1/60 秒後清除畫面
  • 在canvas上面繪製第二幀的樣子
  • 持續重複上面的做法, 一直做到第60幀

原理面的部分大致上就這樣,雖然看起來很簡單,但是在實作的時候才真的會遭遇到各種問題。

什麼是幀率(FPS)?

你前面提到了FPS這個詞,那是一個什麼樣的概念?

所謂的幀率就是『每秒被播出的圖像數量』。

延伸資源:不同幀率所帶來的視覺效果差

在過去的年代,製作一部手繪卡通其實所費不貲,最主要的原因就是因為圖像需要逐幀人工繪製,而專案限期內能繪製的圖像有限,所以一般來說手繪動畫的幀率相對來講會比較低。
以常見的日式動畫來講,日式動畫的幀率平均落在23.976幀/秒, 這也就是我們常聽到的24幀

在現代則出現了更方便的AI中割模擬,大幅減少了人力成本,不過那是題外話了 。 BTW – 有看咒術迴戰的朋友可能對24幀這詞蠻熟悉的XD(投射咒法~!)

而在科技進步之後,來到網頁 / 遊戲的領域,基本幀率則最少也會有60幀/秒(因為畫面是交由機器繪製)。

這邊有人可能會開始覺得很疑惑,網頁在沒有動畫時,本身不是靜態的圖像嗎?為什麼還會提到幀率?

這你就不懂了~

瀏覽器視窗本身可以其實可以看作一個大型且複雜的canvas,你在一秒內所看到的畫面,其實實際上是瀏覽器渲染引擎以極快的速度逐幀繪製出來的(就算是靜態畫面也是一樣)

渲染引擎本身的繪製速度其實會受到編譯的速度,還有程序邏輯的複雜度等多重原因影響,而當渲染引擎的繪製速度被拖慢(或者說不夠快),這時候就會出現所謂的渲染延遲,或掉幀的現象。

舉個常見的渲染延遲案例 ~ 大家在菜鳥時期一定都有碰過。當有一個網頁具備一卡車的動態特效(尤其是onScroll Animation) ,使用者在網頁載入完畢的同時快速把scrollbar往下拉,這時候畫面會有很大的機率會白掉一大半,這個就是『渲染速度』跟不上『使用者操作UI速度』的典型案例。

通常這種狀況的解決方式都是比較硬核的,需要深入程序修改細節以解決效能上的問題。

而所謂的掉幀,其實就是我們常說的動畫卡頓問題,網頁的掉幀問題有大有小,大的例如畫面卡頓卡到跟靜態圖像一樣,小的則像是某些時候畫面會有微妙的停頓感(一般人可能分辨不出來這個XD)

接下來我們會用實際範例來演示怎麼在Canvas環境下實現一個方塊移動的簡易動畫。

實際動畫案例演示 - 方塊移動

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
const startTime = performance.now();
const durationTotal = 5000;

function drawRect(ctx,x,y){
// 這就是很普通的畫一個方塊在指定座標的位置上
// 假設長寬都是40
const size = 20;
// 設定填充色
ctx.fillStyle="#fff";
ctx.fillRect(x,y,size*2,size*2);

}

function animate(ctx){
// 預設都先清除舊畫面然後重新畫一個方塊在新位置
let timeNow = performance.now() - startTime;
//
const speed = 0.05; //假設速度是0.05px/毫秒

ctx.clearRect(0,0,ctx.canvas.width,ctx.canvas.height)
drawRect(ctx,speed*timeNow,50);

// 在目前花費時間還沒超過總預設花費時間的狀況下就持續動作
if(timeNow<durationTotal){
// 告訴瀏覽器下一幀要做的動作, 可以想像成一個極短的setTimeout, delay時間大約是1/60秒
// 這邊我們透過遞迴執行animate來繪製下一幀的畫面
requestAnimationFrame(()=>{animate(ctx)})
}
else{
return
}
}


function draw(){
const cvs = document.querySelector('canvas');
const ctx = cvs.getContext('2d');
animate(ctx);
}


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

codepen連結: https://codepen.io/mizok_contest/pen/qBjyVaa

在上面這個看似簡單的範例中其實隱藏著兩個重點。

  • 用requestAnimationFrame(RAF)來告訴瀏覽器進行下一幀圖像的繪製
  • 用performance.now()來追蹤動畫進行時間

接下來我們會就這兩個重點講解背後的原因。

為什麼要使用requestAnimationFrame()?

如果去找一些比較早期的Canvas教程,可能會發現它裡面都是用SetTimeOut 或是SetInterval去計算幀間時差(將時間設置為1000/60 毫秒) 。
雖然說用setTimeOut/setInterval 來計算幀間時差並不算錯,但是就是比較粗糙。
使用requestAnimationFrame(以下簡稱RAF)的優點有二:

  • 不像setTimeOut/setInterval是固定給一個固定的時間差,RAF可以視為一個動畫幀結束之後的callback,所以相對的他比較不會受到幀率誤差的影響
  • 不像setTimeOut/setInterval,RAF並不會在背景運作,當你把分頁切換到別的分頁,RAF就會被中斷,這個設計改善了瀏覽器運作的效能,同時也減少電源的消耗

為什麼要使用performance.now()?

我們先來說說performance.now()在這個案例裡面用途是什麼,還有他是一個什麼樣的api。

performance.now()簡單來說就是一個用來計算document生命週期的方法,他會在document物件被載入的時候開始計時。

有些人接著可能會問:

那他跟Date.now()差別差在哪?不能用Date.now()就好嗎?

performance.now()作為一個比較年輕的api,跟Date.now()比起來其實有更多適用的場景,原因有二:

  • performance.now() 能夠提供超越毫秒的精準度,他所計算出來的時間會是附帶浮點數的毫秒,所以他更適合用在遊戲或動畫這種需要高精確度的運算場景
  • Date.now()實際上是從1970年1月1日0分0秒開始估算(也就是所謂的Unix時間),然而現今的年代其實很少需要一個從1970年開始計時的功能。而且Date實際上是會Base on 裝置的系統時間,當系統時間在某種狀況下受到變動,運用Date.now去計算時間差的Web APP 可能會出現誤差。

根據developers.google.com的解釋,Date.now比較適用在確認絕對時間的場景,而performance.now則適用於計算相對時間的情境。

developers.google.com上關於performance.now的解釋可以看這邊

看到這邊大家應該已經很能理解使用performance.now的諸多好處~

但是接著可能就又會有人再問:

那為什麼不能直接用經過的幀數作計算?例如預設一個變數給定總幀數,然後每一圈RAF就-1,扣到0的時候就停止運動?

原因很簡單~還記得我們前面有介紹過瀏覽器的FPS數字其實會受到其他因素的影響嗎?有時候如果FPS偏低,那麼就意味著可能有某幾幀的耗時比較長,這麼一來,如果用幀數來判斷移動距離,就會出現運動速度不均勻的狀況。

雖然說在過去這種誤差可能不容易被察覺,但是在現代,尤其是在開發遊戲的場景,物件移動的精確度其實越來越重要,所以相對的也要求開發人員不能隨便用舊方法交差。

小結

這次我們介紹了如何在Canvas上實作動畫,但是這其實還是非常基本的部分,我們接下來即將要脫離基礎篇,正式來介紹一些比較複雜的Canvas應用場景~

路徑繪製常令人感到疑惑的點 - 非零纏繞與奇偶規則

初學路徑繪製的時候,大部分人應該會發現一種讓人疑惑的狀況。

那就是當繪製的路徑稍微複雜一點且路徑線段產生交錯的時候,有些透過路徑線圍起來的區域,在發動ctx.fill()填充顏色
之後,仍然維持未填充的狀態。

之所以產生這種狀況的原因,是因為『你的大腦』和『程式邏輯』判斷封閉區域的規則不一樣。

而這篇文章的重點就在於講解『程式邏輯』判斷一個『路徑』是否存在『封閉區域』的判斷依據。

這個『判斷依據』一共有兩種模式,一種稱為『非零纏繞(Nonzero)』,另外一種則叫做『奇偶規則(Evenodd)』。

試著畫一個因為線段交錯而產生複雜封閉區的路徑

能最簡單體現這兩種判斷邏輯差別的方式就是畫兩個五角星,然後在ctx.fill()這個方法內導入填充模式的參數(也就是”evenodd” or “nonzero”)。

ctx.fill() 的參數型別相關資訊可以看這篇MDN上的介紹

See the Pen 非零纏繞與奇偶規則介紹1 by mizok_contest (@mizok_contest) on CodePen.

好啦, 我知道我的五角星很醜, 不要再嫌了

這邊我們可以發現,左邊evenodd規則所畫出來的圖形,中間並沒有被填滿,但是nonezero規則下的圖形卻是相反過來的狀況。

這是為什麼? 接下來我們就是要來解釋這兩種規則的差異。

解釋非零纏繞與奇偶規則

非零纏繞(nonzero)、奇偶規則(evenodd) 其實是在電腦圖學一個很常見的概念(SVG也會牽涉到這兩個東西),這兩種概念是用在“當判斷一個座標是否處於一個封閉路徑內部時”,採用的兩種基準點。

上圖是同一個path , 採用不同的規則時,在被Fill之後的樣子
我們可以看到這個path是由相同的一組編號1~5的向量線所形成的一個path。

基本上這兩種模式判斷的依據都是透過向量的改變狀況,還有向量的夾角來判定。

這邊我們先來複習一下高中的數學/物理~所謂的向量,指的是一種從座標A移動到座標B的附帶方向的移動量。
而『向量的夾角』指的則是兩條向量之前夾的最小角度(意思就是說『夾角』永遠是指小於180度的那個角)。

另外,夾角的計算~必須要是讓兩組向量從同一個座標點出發才能夠判定

像下面這邊的案例是透過把A向量拉出延伸, 直到A向量與B向量自同一座標出發

我們在接下來的講解其實還會提到向量夾角的正負值,所以我們這邊也簡單的做一些說明:

向量夾角正負的判斷, 這邊就會牽涉到我們前面講到的canvas座標系問題

還記得我們前面有提到過canvas的座標系是屬於左手定則坐標系嗎?而且左手定則坐標系是『順時針方向為正

當我們有兩條向量(A與B), 假設今天我們要讓A轉變成B, 其實可以想像有一台以A向量方向前進的車,而突然這台車受到某種外力的干涉,導致車子必須變成以B向量方向行駛:

向量夾角


BTW,對高中數學還有印象的人可能還會記得這個公式~

假設有一個向量圍成的三角形如下:

如果我們要求取AC向量和AB向量的夾角,則可以透過這個公式來求得

而這樣的公式因爲完全是數理邏輯,所以我們其實也可以把它改寫成程式

接下來我們看看兩種規則是怎麼透過向量夾角機制來判定封閉區域是否存在 :D

非零纏繞(nonzero)

由點A向外隨便一個方向拉一條無限延伸的線(淡藍色的線),當這條線和1~5編號的向量交接時,若交接的夾角是呈逆時針,則-1,若為順時針則+1,最後的總和若不為0,代表點A在Path內部(也就是說A在一個封閉路徑內部),若為0則反之。

奇偶規則(evenodd)

奇偶規則的判定比較簡單,同時他也跟向量判定沒太大關係。

由點A向外隨便一個方向拉一條無限延伸的線(淡藍色的線),當這條線和1~5編號的向量交接時,每碰到一條線就+1,
最後的總和若為奇數,代表點A在Path內部(也就是說A在一個封閉路徑內部),若為偶數則反之。

小結

一般來說,大部分情況下evenodd的填充方式不會去涵蓋到shade region
(就是容易因為模式改變而轉變為 開放/封閉 區域 的地方)。

所以當我們想要用path去畫一個鏤空的圖形,一般會先把fillRule 改成evenodd。

但是,evenodd & 鏤空 這兩件事其實不是充要條件,而是就統計學上來講,evenodd模式容易創造出比較多的鏤空區。

根據繪製路徑的細節,nonzero模式同樣也可能創造出鏤空區。例如下面這個案例。

這個路徑是以nonzero方式填充,但卻仍然有鏤空區存在。