三角形的 N 種畫法與瀏覽器的開放世界

doodlewind發表於2018-05-02

最近,我完全沉迷在了任天堂 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:"\t666" }
複製程式碼

這樣一個 <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 = 'red'
ctx.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 的膠水程式碼佔了絕對的大頭。按照計算機圖形學按部就班的教程,即便只是完成一個三角形的渲染過程,也需要百行左右的程式碼。限於篇幅,我們只簡要地將這個流程裡所需要做的關鍵事項概括為以下三步:

  1. 用 GLSL 語言編寫頂點著色器和片元著色器。
  2. 定義出一個頂點緩衝區,向其中傳入三角形逐個頂點的資料。
  3. 在我們自己實現的 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 四種語言的十幾種方案來畫,更復雜的場景下就更是百花齊放了。瀏覽器的渲染能力之強應該也算得上是個開放世界了吧:別管你想畫什麼,總有適合你的方法去實現。

不過和塞爾達裡越高階的操作看起來越風騷簡潔不同,越是掌控力強的技術方案,在實現上就會更加複雜。但總之不管是遊戲還是程式碼還是生活,相信快樂的方式都不止一種~希望大家都能夠享受過程,找到屬於自己的那份樂趣~

相關文章