三角形的 N 種畫法與瀏覽器的開放世界
來自專欄 前端隨想錄
最近,我完全沉迷在了任天堂 Switch 上的《塞爾達傳說:荒野之息》里,以至於專欄都快要停更了(罪過罪過)。大概每個塞爾達玩家都會有這個疑問,那就是這個遊戲為什麼這麼好玩?!非常有意思的是,這個問題的答案似乎和「前端為什麼這麼日新月異」有著微妙的關係,這讓我有了一些全新的認識…
塞爾達的遊戲體驗有一點廣受好評,那就是符合直覺的開放世界。換句話說,在這個遊戲里想要做到一件事,只要你能想到什麼方式,那麼你幾乎就能基於這種方式去實現。比如,你看到樹上掛著一顆蘋果,那麼想要摘下這顆蘋果,至少有以下這些辦法:
- 把樹砍倒,撿到蘋果
- 爬樹、騎在馬上或者搬來箱子墊腳夠到蘋果
- 用弓箭把蘋果射下來
- 扇風或者炸彈製造衝擊波,把蘋果吹下來
- 從周圍的高地滑翔到蘋果樹上
- 放火把樹點著,留下烤蘋果
- ……
這種自由度使得遊戲的冒險體驗充滿了驚喜。對各種棘手的機關謎題,解法常常是開放而不唯一的。巧的是,我近期的工作也和折騰前端的各種渲染機制有些關係。當用自由程度來評價瀏覽器的時候,能看到的幾乎也是一個塞爾達級別的開放世界了。
我們不妨用三角形作為例子吧。三角形作為最簡單的幾何圖形,繪製它對於任何一位前端同學都不會是一件難事。但在今天的前端領域裡,到底有多少種技術方案能夠畫出一個三角形呢?答案可以說非常的百花齊放了。讓我們循序漸進地開始吧。下面的各種套路可以按照折騰程度分為三種:
- 2B Play
- 普通 Play
- 羞恥 Play
2B Play
首先讓我們從最不費勁的耍無賴方法開始吧:
字元
還有什麼比複製粘貼一個 △
字元更簡單的繪製方式呢?這其實就是個形如 u25b3
的 Unicode 特殊字元而已。
圖片
看起來 <img src="三角形.jpg"/>
的套路很 low,但完全沒毛病啊??
HTML
只要垂直居中一系列寬度均勻增長的矩形,我們是不是就得到了一個三角形呢??
<div class="triangle"> <div style="width: 1px; height: 1px;"></div> <div style="width: 2px; height: 1px;"></div> <div style="width: 3px; height: 1px;"></div> <div style="width: 4px; height: 1px;"></div> <!-- ...... --></div>
Demo
普通 Play
如果感覺上面的實現太過於玩世不恭,接來下我們可以用一些略微「正常」一點的操作來畫出同樣的三角形:
CSS
CSS 里充斥著大量的奇技淫巧,而下面這個操作可能是很多面試題的標準答案了。我們只需要簡單的 HTML:
<div class="triangle"></div>
配合魔改容器邊框的樣式:
.triangle { width: 0; height: 0; border-left: 50px solid transparent; border-right: 50px solid transparent; border-bottom: 100px solid red;}
就能夠模擬出一個三角形了。Demo
Icon Font
把字體當做圖標使用的做法也是老調重彈了。只需要大致這樣的字體樣式配置:
@font-face { font-family: Triangle; src: url(./triangle.woff) format("woff");}.triangle:before { content:" 666" }
這樣一個 <i class="triangle"></i>
的標籤,就能通過 :before
插入特殊字元,進而渲染對應的圖標字體了??Demo
SVG
很多時候我們習慣把 SVG 當做圖片一樣的靜態資源直接引入使用,但其實只要稍微了解一下它的語法後,就會發現直接手寫 SVG 來繪製簡單圖形也並不複雜:
<svg width="100" height="100"> <polygon points="50,0 100,100 0,100" style="fill: red;"/></svg>
Demo
Clip Path
SVG 和 CSS 有很多相似之處,但 CSS 雖然長於樣式,長久以來卻一直缺乏「繪製出一個形狀」的能力。好在 CSS 規範中剛加入不久的 clip path 能夠名正言順地讓我們用類似 SVG 的形式繪製出更多樣的形狀。這隻需要形如下面的樣式:
.triangle { width: 10px; height: 10px; background: red; clip-path: polygon(50% 0, 0 100%, 100% 100%);}
這和熟悉的 border 套路有什麼區別呢?除了代碼更直觀簡潔以外,它還能夠為繪製出的形狀支持背景圖片屬性,可惜的地方主要是 IE 兼容了。Demo
Canvas
到目前為止的方法沒有一個需要編寫 JS 代碼,這多少有些對不起工錢。還好我們有 Canvas 來名正言順地折騰。只需要一個 <canvas>
標籤配上這樣的膠水代碼就行:
const canvas = document.getElementById(canvas)const ctx = canvas.getContext(2d)ctx.beginPath()ctx.fillStyle = redctx.moveTo(50, 0)ctx.lineTo(0, 100)ctx.lineTo(100, 100)ctx.fill()
Demo
羞恥 Play
如果你還是嫌棄上面的操作過於中規中矩,讓我們用最後的幾種方法來探索瀏覽器的自由尺度吧:
CSS Houdini
近期的 CSS 大會上 CSS Houdini 可以說賺足了眼球。這套大大增強 CSS 控制力的規範中,目前已經實裝的主要也就是 CSS Paint 了。簡而言之,通過這個 API,只要 CSS 屬性需要圖片的地方,你就可以編程式地通過 canvas 控制圖片的渲染過程。
通過 CSS.paintWorklet.addModule
API,我們可以定義繪製 canvas 所用的 paint worklet:
<script> CSS.paintWorklet.addModule(/worklet.js)</script>
Paint worklet 中能夠拿到正常的 canvas 上下文:
class TrianglePainter { paint(ctx, geom, properties) { const offset = geom.width ctx.beginPath() ctx.fillStyle = red ctx.moveTo(offset / 2, 0) ctx.lineTo(offset, offset) ctx.lineTo(0, offset) ctx.fill() }}registerPaint(triangle, TrianglePainter)
只要這樣,就能在 CSS 里使用 paint
規則了:
.demo { width: 100px; height: 100px; background-image: paint(triangle);}
我們還可以使用 CSS Variable 在 CSS 中定義形如 --triangle-size
或 --triangle-fill
的參數,來控制 canvas 的渲染,這樣在參數更新時 canvas 會自動重繪。結合上 animation,它在特效領域的想像空間也很大。雖然最後使用的還是前面提及的 canvas,但 Houdini 確實給基於 CSS 的渲染帶來了更大的掌控。
WebGL 多邊形
主流瀏覽器對 WebGL 的支持已經相當不錯了,但目前看來它仍然不是前端領域人人必備的主流技術。這或許和它較為陡峭的學習曲線有關。可能有不少同學對 WebGL 有一種誤解,即它和 canvas 一樣,是一套 JS API。實際上,編寫 WebGL 應用時,除了需要編寫運行在 CPU 範疇內的 JS 膠水代碼外,真正在 GPU 上執行的是 GLSL 語言編寫的著色器。但是由於繪圖庫本身的複雜性,在入門示例中,JS 的膠水代碼佔了絕對的大頭。按照計算機圖形學按部就班的教程,即便只是完成一個三角形的渲染過程,也需要百行左右的代碼。限於篇幅,我們只簡要地將這個流程里所需要做的關鍵事項概括為以下三步:
- 用 GLSL 語言編寫頂點著色器和片元著色器。
- 定義出一個頂點緩衝區,向其中傳入三角形逐個頂點的數據。
- 在我們自己實現的 render 函數里做一些準備。在載入完著色器程序後,調用
drawArray
API 繪製緩衝區中數據。
這個過程(Demo)初看之下控制的不過是一個更啰嗦而折騰的 canvas 而已,除了可以支持 3D 以外,有什麼不同呢?在最後一種方法里我們就能看到區別了。
WebGL 造型函數
上面的流程基本是每一個 WebGL 教程都會按部就班地去做的。考慮這個問題:繪製三角形一定需要提供三個頂點嗎?這可不一定。
熟悉 canvas 的同學都知道,在處理圖像時,像下面這樣的逐像素操作很容易帶來性能問題:
for (let i = 0; i < width; i++) { for (let j = 0; j < height; j++) { // ... }}
但是在 WebGL 中,是不存在這樣串列的循環的。你用 GLSL 語言所編寫的著色器,會被編譯到 GPU 上去並行執行。聽起來是不是比較酷?上面已經提到,我們有兩種著色器,即頂點著色器和片元著色器:
- 頂點著色器的代碼逐頂點執行,比如對於三角形,它就執行三次。
- 片元著色器的代碼逐片元(粗略的理解就是像素)執行,對於一個 100x100 的區域,GPU 會並行地對這 1w 個像素調用片元著色器,這個並行的過程對你是透明的。
所以對於一個「逐像素執行」的片元著色器來說,只要它知道自己每次被調用時所在的坐標,那麼就能夠根據這個位置計算出最終的顏色。這樣一來,我們甚至不需要頂點緩衝區,就能夠基於特定的公式去計算逐像素的顏色了。這樣為著色器設計的函數我們稱為 shaping function,即造型函數。一個正多邊形的著色器形如:
#define TWO_PI 6.28318530718// 由 JS 傳入的屏幕解析度uniform vec2 u_resolution;void main() { vec2 st = gl_FragCoord.xy/u_resolution.xy; st.x *= u_resolution.x/u_resolution.y; vec3 color = vec3(0.0); float d = 0.0; // 重新映射空間坐標到 -1. 與 1. 間 st = st * 2.-1.; // 多邊形邊數量 int N = 3; // 當前像素的角度與半徑 float a = atan(st.x,st.y)+PI; float r = TWO_PI/float(N); // 調節距離的造型函數 d = cos(floor(.5+a/r)*r-a)*length(st); color = vec3(1.0-smoothstep(.4,.41,d)); // color = vec3(d); gl_FragColor = vec4(color,1.0);}
這就是一個船新的領域了,由於 shader 編程要求對眾多的像素編寫出同一份簡潔而並行執行的代碼,彼此之間還完全透明且無法隨意 log 調試,這使得面向著色器編程的門檻實際上很高。這裡的示例在非常好的入門書 The Book of Shaders 中有相應的章節,有興趣的同學或許會打開新世界的大門哦??
P.S. 在這裡我們為什麼要捨近求遠呢?這個途徑其實和字體渲染的原理有些接近,近期我也在學習一些相關的知識,希望屆時能有更多的內容可以分享~
總結
不可否認,常規的業務開發很容易進入枯燥的重複勞動階段,但再看開一點,我們可以發現實際上我們已經有了非常多可用的技術手段來優化前端這個領域裡的交互了。一個簡單的三角形都能用 HTML / CSS / JS / GLSL 四種語言的十幾種方案來畫了,更複雜的場景下就更是百花齊放了。瀏覽器的渲染能力之強應該也算得上是個開放世界了:別管你想畫什麼,總有適合你的方法去實現。
不過和塞爾達里越高級的操作看起來越風騷簡潔不同,越是掌控力強的技術方案,在實現上就會更加複雜。但總之不管是遊戲還是代碼還是生活,相信快樂的方式都不止一種~希望大家都能夠享受過程,找到屬於自己的那份樂趣~
推薦閱讀:
※Web App防坑手冊
※React 16 更新一覽
※從新的 Context API 看 React 應用設計模式
※Web 後端第 10 期、Web 前端第 9 期報名公告
※木犀互聯網技術周刊(第二十六期)