多圖預警,數學不好可直接跳至文末小結。
需求背景
從一個遊戲需求說起:
- 技術選型:canvas
上圖所展示的遊戲場景,“可樂瓶”裡有多個“氣泡”,需要設定不同的動畫效果,且涉及 deviceOrientation 的互動,需要有大量計算改變元素狀態。從效能方面考慮,canvas 是不二的選擇。 - 技術點:canvas 繪製影象
通過對遊戲場景的進一步分析,可見場景中的“氣泡”元素形狀都是相同的,且不規則,通過 canvas 直接繪製形狀實現成本較高,因此需要在 canvas 上繪製影象。 - 技術點:canvas 影象旋轉與翻轉
雖然“氣泡”元素是相同的,可以使用相同的影象,但影象需要多個角度/多個方向展示,因此需要對影象進行相應的旋轉與翻轉(映象),這也是本文所要介紹的重點。
後文程式碼以下圖左側綠框的“氣泡”為示例,右側展示了場景中用到的兩個影象:
認識 canvas 座標系
canvas 上影象的旋轉和翻轉,常見的做法是將 canvas 座標系統進行變換。因此,我們需要先認識 canvas 座標系統:
由上圖可得,canvas 2D 環境中座標系統和 Web 的座標系統是一致的,有以下幾個特點:
- 座標原點 (0,0) 在左上角
- X座標向右方增長
- Y座標向下方延伸
回到上述需求中,我們獲取 canvas 物件並設定相應的寬高:
1 |
<canvas id='myCanvas'></canvas> |
1 2 3 4 5 6 |
// 獲取 canvas 物件 var canvas = document.getElementById('myCanvas') canvas.width = 750 canvas.height = 1054 // 獲取 canvas 2D 上下文物件 var ctx = canvas.getContext('2d') |
此時,canvas 的座標系統如下圖所示:
在 canvas 上繪製影象
在 canvas 上繪製影象,可以使用 drawImage()
方法,語法如下(詳細用法參見 MDN):
1 2 3 |
void ctx.drawImage(image, dx, dy); void ctx.drawImage(image, dx, dy, dWidth, dHeight); void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight); |
需要注意的是,影象必須載入完畢,才能繪製到 canvas 上,否則會出現空白:
1 2 3 4 5 6 |
var img = new Image() img.src = 'xxxxxxx.png' img.onload = function() { // 繪製影象 ctx.drawImage(img, 512, 220, 160, 192); } |
此時,便可以 canvas 上看到一個未旋轉/翻轉的“氣泡”影象,如下圖所示:
canvas 座標變換
接下來,我們再來了解 canvas 座標的變換。上述需求僅涉及 2D 繪製上下文,因此僅介紹 2D 繪製上下文支援的各種變換:
- 平移 translate:
1ctx.translate(x, y)translate() 方法接受兩個引數。x 是左右偏移量,y 是上下偏移量。
- 旋轉 rotate:
1ctx.rotate(angle)rotate() 方法只接受一個引數。旋轉的角度 angle,它是順時針方向的,以弧度為單位的值。
- 縮放 scale:
1ctx.scale(x, y)scale() 方法接受兩個引數。x 和 y 分別是橫軸和縱軸的縮放因子。其縮放因子預設是 1,如果比 1 小是縮小,如果比 1 大則放大。
- 變形 transform:
1ctx.transform (a, b, c, d, e, f)transform() 方法是對當前座標系進行矩陣變換。
1ctx.setTransform (a, b, c, d, e, f)setTransform() 方法重置變形矩陣。先將當前的矩陣重置為單位矩陣(即預設的座標系),再用相同的引數呼叫 transform() 方法設定矩陣。
以上兩個方法均接受六個引數,具體如下:
引數 | 含義 |
---|---|
a | 水平縮放繪圖 |
b | 水平傾斜繪圖 |
c | 垂直傾斜繪圖 |
d | 垂直縮放繪圖 |
e | 水平移動繪圖 |
f | 垂直移動繪圖 |
影象旋轉的實現
上圖所示“氣泡”,寬為 160,高為 192,x 軸方向距離原點 512,y 軸方向距離原點 220,逆時針旋轉 35 度。
要繪製該“氣泡”,需要先將座標系平移(translate),再旋轉(rotate)。具體實現步驟如下:
save() 方法與 restore() 方法:
- save() 方法用來儲存 Canvas 狀態的,沒有引數。每一次呼叫 save() 方法,當前的狀態就會被推入棧中儲存起來。當前狀態包括:
- 當前應用的變形(移動/旋轉/縮放)
- strokeStyle, fillStyle, globalAlpha, lineWidth, lineCap, lineJoin, miterLimit, shadowOffsetX, shadowOffsetY, shadowBlur, shadowColor, globalCompositeOperation 的值
- 當前的裁切路徑(clipping path)
- restore() 方法用來恢復 Canvas 狀態,沒有引數。每一次呼叫 restore() 方法,上一個儲存的狀態就從棧中彈出,所有設定都恢復。
- 狀態儲存在棧中,可以巢狀使用 save() 與 restore()。
影象翻轉的實現
上圖所示“氣泡”,寬為 160,高為 192,x 軸方向距離原點 172,y 軸方向距離原點 365,順時針旋轉 35 度。
要繪製該“氣泡”,需要先將座標系統平移(translate),翻轉(scale),平移(translate),再旋轉(rotate)。具體實現步驟如下:
至此,實現了“氣泡”的映象翻轉,但翻轉後的“氣泡”還需要旋轉特定的角度,在方法一的基礎上繼續對座標系統進行變換:
以上操作中進行了兩次平移(translate)操作,可以進行合併簡化:
座標系統的矩陣變換
前文介紹了 2D 繪製上下文變形(transform)變換,實際是直接修改變換的矩陣,它可以實現前面介紹的平移(translate)/旋轉(rotate)/縮放( scale)變換,還可以實現切變/映象反射變換等。矩陣計算遵循數學矩陣公式規則:
由上公式可得:
1 2 |
x' = ax + cy + e y' = bx + dy + f |
矩陣變換可實現以下變換效果:
- 平移 translate:
12x' = 1x+0y+tx = x+txy' = 0x+1y+ty = y+ty - 旋轉 rotate:
12x' = x*cosθ-y*sinθ+0 = x*cosθ-y*sinθy' = x*sinθ+y*cosθ+0 = x*sinθ+y*cosθ - 縮放 scale:
12x' = Sx*x+0y+0 = Sx*xy' = 0x+Sy*y+0 = Sy*y - 切變
12x' = x+y*tan(θx)+0 = x+y*tan(θx)y' = x*tan(θy)+y+0 = x*tan(θy)+y - 映象反射
12345// 定義(ux,uy)為直線(y=kx)方向的單位向量ux=1/sqrt(1+k^2)uy=k/sqrt(1+k^2)x' = (2*ux^2-1)*x+2*ux*uy*yy' = 2*ux*uy*x+(2*uy^2-1)*y
結合上述公式,可推匯出影象旋轉和翻轉的矩陣變換實現:
- 影象旋轉:
- 影象翻轉:
- 影象映象反射(翻轉+旋轉):
畫素操作實現影象翻轉
除了座標系統變換,canvas 的畫素操作同樣可以實現影象的翻轉。首先需要了解下 getImageData()
方法(詳細用法參見MDN)和 putImageData()
(詳細用法參見MDN)方法:
- getImageData()
CanvasRenderingContext2D.getImageData()
返回一個 ImageData 物件,用來描述 canvas 區域隱含的畫素資料,這個區域通過矩形表示,起始點為 (sx, sy)、寬為 sw、高為 sh。
1ImageData ctx.getImageData(sx, sy, sw, sh); - putImageData()
CanvasRenderingContext2D.putImageData()
是 Canvas 2D API 將資料從已有的 ImageData 物件繪製到點陣圖的方法。 如果提供了髒矩形,只能繪製矩形的畫素。
1 2 |
void ctx.putImageData(imagedata, dx, dy); void ctx.putImageData(imagedata, dx, dy, dirtyX, dirtyY, dirtyWidth, dirtyHeight); |
水平翻轉實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// 繪製影象 ctx.drawImage(img, x, y, width, height) // 獲取 img_data 資料 var img_data = ctx.getImageData(x, y, width, height), i, i2, t, h = img_data.height, w = img_data.width, w_2 = w / 2; // 將 img_data 的資料水平翻轉 for (var dy = 0; dy < h; dy ++) { for (var dx = 0; dx < w_2; dx ++) { i = (dy << 2) * w + (dx << 2) i2 = ((dy + 1) << 2) * w - ((dx + 1) << 2) for (var p = 0; p < 4; p ++) { t = img_data.data[i + p] img_data.data[i + p] = img_data.data[i2 + p] img_data.data[i2 + p] = t } } } // 重繪水平翻轉後的圖片 ctx.putImageData(img_data, x, y) |
小結
至此,小編的數學姿勢又恢復到了高考水平。
- 影象旋轉:
- 基礎變換法:
12345ctx.save()ctx.translate(x + width / 2, y + height / 2)ctx.rotate(angle * Math.PI / 180)ctx.drawImage(img, -width / 2, -height / 2, width, height)ctx.restore() - 矩陣變換法:
12345ctx.save()var rad = angle * Math.PI/180ctx.transform( Math.cos(rad), Math.sin(rad), -Math.sin(rad), Math.cos(rad), x + width / 2, y + height / 2)ctx.drawImage(img, -width / 2, -height / 2, width, height)ctx.restore()
- 基礎變換法:
- 影象翻轉:
- 基礎變換法:
123456// 方法一ctx.save()ctx.translate(canvasWidth, 0)ctx.scale(-1, 1)ctx.drawImage(img, canvasWidth-width-x, y, width, height)ctx.restore()
12345// 方法二ctx.save()ctx.scale(-1, 1)ctx.drawImage(img, -width-x, y, width, height)ctx.restore() - 矩陣變換法:
12345// 方法一ctx.save()ctx.transform(-1, 0, 0, 1, canvasWidth, 0)ctx.drawImage(img, canvasWidth-width-x, y, width, height)ctx.restore()
12345// 方法二ctx.save()ctx.transform(-1, 0, 0, 1, 0, 0)ctx.drawImage(img, -width-x, y, width, height)ctx.restore() - 畫素操作法:
123456789101112131415161718ctx.drawImage(img, x, y, width, height)var img_data = ctx.getImageData(x, y, width, height),i, i2, t,h = img_data.height,w = img_data.width,w_2 = w / 2;for (var dy = 0; dy < h; dy ++) {for (var dx = 0; dx < w_2; dx ++) {i = (dy << 2) * w + (dx << 2)i2 = ((dy + 1) << 2) * w - ((dx + 1) << 2)for (var p = 0; p < 4; p ++) {t = img_data.data[i + p]img_data.data[i + p] = img_data.data[i2 + p]img_data.data[i2 + p] = t}}}ctx.putImageData(img_data, x, y)
- 基礎變換法:
- 影象映象對稱(翻轉+旋轉):
- 基礎變換法:
123456ctx.save()ctx.scale(-1, 1)ctx.translate(-width/2-x, y+height/2)ctx.rotate(-angle * Math.PI / 180)ctx.drawImage(img, -width / 2, -height / 2, width, height)ctx.restore() - 矩陣變換法:
1234567ctx.save()var k = Math.tan( (180-angle)/2 * Math.PI / 180 )var ux = 1 / Math.sqrt(1 + k * k)var uy = k / Math.sqrt(1 + k * k)ctx.transform( (2*ux*ux-1), 2*ux*uy, 2*ux*uy, (2*uy*uy-1), x + width/2, y + height/2 )ctx.drawImage(img, -width/2, -height/2, width, height)ctx.restore()
- 基礎變換法:
參考文章
說明:本文討論的 canvas 環境均為 2D 環境。若有更好的實現方式,歡迎留言告知。