很差意思,標題實際上是開了個玩笑。你們都知道,Canvas 獲取繪畫上下文的 api 是 getContext("2d")。我第一次看到這個 api 定義的時候,就很天然的認爲,既然有 2d 那必定是有 3d 的咯? 可是我接着我看到了 api 介紹的這句話javascript
提示:在將來,若是 canvas 標籤擴展到支持 3D 繪圖,getContext() 方法可能容許傳遞一個 "3d" 字符串參數。html
what? 我有一句媽賣批不知當講不當講... 從接觸 canvas 以後我就一直等這個將來,等到後來我學習 three.js... 再等到如今,這個 getContext("3d") 仍是沒有出來。多是由於愈來愈多瀏覽器都已經支持 webGL 的緣由把,這個 getContext("3d") 有可能不再會來了。前端
webGL 就是瀏覽器端的 3D 繪圖標準,它直接藉助系統顯卡來渲染 3D 場景,它能製做的 3D 應用,是普通 canvas 沒法相比的。因此,你有複雜的 3D 前端項目,且不考慮 IE 的兼容性的話。不用說,直接使用 webGL 吧。java
然而,有的時候咱們只須要實現簡單的 3D 效果。在沒有學習 webGL 或這方面的框架的狀況下,咱們其實也能夠在普通的 canvas api 基礎上製做出來。並且,咱們能夠兼容 IE 9。先來看看,咱們都能作些什麼效果。git
https://www.meizu.com/products/pro6/summary.htmlgithub
https://www.meizu.com/products/pro6/performance.htmlweb
這的兩個效果都是工做時簡單的 3D 效果需求,沒有必要使用 webGL。然而當時我並無使用今天介紹的辦法,由於沒有擴展到 3D 座標去實現因此只能很繁瑣的轉換成 2D 平面圖形分析出來。canvas
若是當時能使用今天介紹方法,將能夠很簡單、在很短期就能實現。api
由於平時之前在學校的時候學習過素描,如今日常也會簡單畫一點,因此對素描知識我有一點點了解。畫畫描繪真實世界的三維場景,須要用到透視。這裏我固然不介紹太多,簡單來講就是咱們理解的近大遠小,能夠用簡單的線條鏈接表示出來。兩條平行的直線在無窮遠的地方看起來會聚集到一塊兒,而聚集的點,在透視裏稱做消失點。經過找到這個消失點,還有平行線,就能夠畫出簡單的立體感受的圖像。瀏覽器
觀察上面這幅圖,在這裏所畫的三維空間,全部的直線都是垂直與畫面的,也就是所,若是用座標描述每條直線上的任一點 v(x,y,z) 他們的 x,y 都是相等的。在畫面上,離咱們眼睛觀察點越遠的點,就越趨向與眼睛觀察點的 x,y 。 那三維空間的座標 v(x,y,z),對應到平面的座標 p(x',y') 其中這個 x,y 會隨着 z 的變化,是否是會呈現必定的規律對應到 x', y' 呢?
我想起了中學學習過的一節物理課。小孔成像
三維空間的火焰,透太小孔,在二維成像屏上顯示了二維的畫面。那時候老師教咱們,這其實最簡單的照相機,和咱們眼睛同樣,光透過瞳孔,最終到達視網膜,在轉換成咱們看到的影像。照相機模擬咱們的眼睛,因此拍出來的照片和咱們眼睛看到的感受是同樣的。
咱們試着把剛纔的實驗轉換到簡單的幾何座標中看。
觀察 yz (x=0) 截面,假設小孔爲座標原點 (0,0,0) 成像屏到小孔的距離爲 d,圖中火焰上的一個點 a(0,y,z) 投射到成像屏對應點 a2,能夠求的 a2 在成像屏中的平面座標:x2 = 0, y2 = y * (d/z)。我天,這麼簡單就找到了這個對應關係? 先別急,爲了方便開發,咱們還須要作一點小轉換。
在 CSS 3D transform 中,咱們須要定義 perspective 屬性,用來講明觀察點到屏幕的距離。若是一個點的 z 軸是 0, 那這個點是處於二維點同樣的位置。z 軸越小(遠離屏幕),對應到屏幕上的顯示的點 xy 就越趨向於定義 perspective 屬性容器的中心,也就是觀察點、眼睛對應到屏幕的 xy。咱們的目標就是用這種 CSS 3D 的方式表示三維的座標(z = 0 的時候三維座標的 xy 是和屏幕座標的 xy 同樣的),而後再套用咱們找到的公式,計算出對應到屏幕中的二維座標是多少,而後咱們就能夠用三維座標描述點的位置,真正在 canvas 繪畫的時候呢,經過簡單的轉換,用計算出來的二維座標繪畫。
上一步求的 a2 對應的平面座標是倒立的(成像屏的火焰也是倒過來的),咱們能夠想一想在小孔與成像屏前方等距的位置放置顯示屏,咱們像 CSS 3D 同樣,讓座標系原點就是顯示屏的中點。而小孔,就成了咱們的觀察點,既眼睛所在的位置,眼睛離顯示屏的距離就是 p(perspective)。由全等三角形的知識能夠知道,上圖中 a2' 恰好是 a2 正過來的座標。咦,看來屏幕座標徹底能夠簡化三維座標點和眼睛的連線與屏幕的交點。這樣,一個三維空間的點座標對應到屏幕座標的關係就找出來了。
既然已經描述出來這個關係了,咱們再用把它表示成簡單的公式。以便直接在代碼中完成三維座標到平面座標的轉換。
已知觀察者到屏幕的距離 p (perspective), 三維空間一個點的座標 a(x,y,z),求這個點在屏幕上的座標。 圖中,三維座標 a 在座標 xy 平面上的向量長度 d 和該點對應到屏幕上的點 a2' 在 xy 平面上的向量長度 d',根據類似三角形,有這樣的關係:
d'/d = p/(p+z)
x 和 y 的值同理:
x'/x = p/(p+z) y'/y = p/(p+z)
原來,三維空間的點座標的 x 和 y 對應到屏幕平面上是關於 z 和 p 成比例變化的這個比例值就是
scale = p/(p+z)
這個 scale 隨着物體到屏幕的距離的值的變大而變小。這也很好地解釋了爲何咱們看東西會近大遠小的緣由:
假設咱們的眼睛看的就是屏幕中央,咱們如今在 y = cvs.height + 5 的 xz 平面上一個正方形區域畫一系列的變長爲 5 的矩形點。若是不作處理,那麼能夠想到咱們直接使用些點的 x, y 座標畫的點,確定在畫布上是看不到的,由於範圍超出了畫布。而真實的世界裏,咱們是能夠看到遠處的點的,遠處的點是趨向與屏幕中央的。
let cvs = document.querySelector('canvas'); let ctx = cvs.getContext('2d'); class Point { constructor(x, y, z) { this.x = x; this.y = y; this.z = z; } } // 根據 perspective 和 z 獲取三維座標對應二維座標的xy縮放值 function getScaleByZ(z, p=600) { let scale; if (z > p) { scale = Infinity; } else { scale = p / (-z + p); } return scale; } function draw() { ctx.clearRect(0,0,cvs.width,cvs.height); let rectWidth = 5; points.forEach((point)=>{ let scale = getScaleByZ(point.z); let drawX = center.x + (point.x - center.x) * scale; let drawY = center.y + (point.y - center.y) * scale; let drawWidth = rectWidth * scale ctx.fillStyle = '#abcdef'; ctx.fillRect(drawX, drawY, drawWidth, drawWidth); }); } let center = new Point(cvs.width/2, cvs.height/2, 0); let points = []; let xCount = 20; // x 方向的點數 let zCount = 20; // z 方向的點數 let step = cvs.width / xCount; // x 方向點之間的間隔 for (let i = -(xCount - 1) / 2; i <= (xCount - 1) / 2; i++) { for (let j = -(zCount - 1) / 2; j <= (zCount - 1) / 2; j++) { let x = i; let z = j; let y = 0; console.log(x,y,z); points.push( new Point((x + xCount/2) * step, cvs.height + 1, z * step) ); } } draw();
在 draw 方法裏,我把三維的座標轉換成了屏幕座標。而且,邊長也根據縮放值從新計算了,遠處的點,邊長越小。代碼最終運行的結果是咱們能夠看到遠處的點,仍是有 3D 的感受的,不過不是很明顯。咱們改變生成點的邏輯,這一次,咱們生成一個球面上的點。
let center = new Point(cvs.width/2, cvs.height/2, 0); let points = []; let circlePointCount = 30; let angelStep = Math.PI * 2 / circlePointCount; let radius = 10; let step = 40; for (let i = -radius; i <= radius; i++) { let y = i; for (let j = 0; j < circlePointCount; j++) { let xzRadius = Math.sqrt(radius * radius - y * y); let xzAngel = j * angelStep; let x = xzRadius * Math.cos(xzAngel); let z = xzRadius * Math.sin(xzAngel); // console.log(x,y,z); points.push( new Point( x * step + cvs.width/2, y * step + cvs.height/2, z * step - cvs.width/2 ) ); } } draw();
或者,再直接讓它旋轉起來。
function update(angelOffset) { points = []; for (let i = -radius; i <= radius; i++) { let y = i; for (let j = 0; j < circlePointCount; j++) { let xzRadius = Math.sqrt(radius * radius - y * y); let xzAngel = j * angelStep + angelOffset; let x = xzRadius * Math.cos(xzAngel); let z = xzRadius * Math.sin(xzAngel); // console.log(x,y,z); points.push( new Point( x * step + cvs.width/2, y * step + cvs.height/2, z * step - cvs.width/2 ) ); } } } (function() { let angelOffset = 0; function tick() { update(angelOffset += 0.006); draw(); window.requestAnimationFrame(tick); } tick(); })();
由於學過 three.js,three.js 有豐富的三維向量計算 api。我從源碼裏提取了這些計算向量的 api 再結合這篇文章裏總結的轉換方法計算二維的座標寫了一個專門在 canvas(2d) 上繪製三維場景的組件,由於是並不是真的是調用3D api,因此我取名字叫 F3.js (fake3D)
https://github.com/gnauhca/f3.js