活用 Shader,讓你的頁面更小,更炫,更快 大尺寸透明背景圖 會動的背景 手繪圖案 結語

活用 Shader,讓你的頁面更小,更炫,更快

可編程着色器(shader)是運行在 GPU 中的程序,是現代圖形渲染技術的基礎。shader 賦予了開發者「逐像素着色」的能力。桌面/移動設備的圖形程序 API 諸如 OpenGL,OpenGL ES,DirectX 以及新一代的 Vulkan,shader 都是重中之重,核心中的核心。

WebGL 的出現,使得在瀏覽器環境中渲染 3D 場景變得輕而易舉。但是 WebGL 和 shader 不僅可以用來渲染 3D 場景,還可以做一些其他酷酷的事情。前兩天,我用 shader 技術改造 / 復刻了之前開發的一個業務頁面,頗有心得和啓發,不妨記錄下來。

廣告:在 GCanvas 的幫助下,前端開發可以在 Weex,RN 等 Hybrid 環境中使用本文中用到的技術。詳情見 GCanvas

先看一下效果: 鏈接

左側是原頁面, 地址 ;右側是用 Shader 復刻後的頁面, 地址

這其實是 2018 年春晚項目的一個活動頁面,頁面結構非常簡單。這個頁面當時是我完成的,所以現在復刻起來熟悉一些。

我們可以看到,復刻前的頁面(後面稱「原頁面」)是靜態的,加載了 1 個 js 文件和 6 張圖片共 599K 的資源(包含一張 502K 的大尺寸透明 png 圖片);而復刻後的頁面上,有不少元素在動,加載了 1 個 js 文件和 4 張圖片共 122K 的資源。不管是視覺效果,還是頁面尺寸上的提升,都是比較明顯的。

下面,我們就以這個頁面爲例,分析一下,使用 shader 是如何讓這個頁面更小,更炫,更快。

閱讀後面的文本需要一些 WebGL 和 GLSL 的基礎知識,之前在團內對曾做過一些培訓,參加的同學應該不會有什麼壓力,沒參加的同學,也可以稍微看下 the book of shaders 這篇教程。shader 比你想象的要簡單易用,相信我。

原頁面存在的一個最大的問題是,有一張特別大的透明背景圖。

這張圖的體積達到了驚人的 501K,這是因爲這張圖是具有透明通道的 png 圖片。而且由於這張圖是廣告內容,可能不止一張,是無法融合到背景裏去的,必須透明。這時怎麼優化呢?
我們知道,具有透明通道的 png 的壓縮是比較困難的;而不具備透明通道的圖片,我們可以把它轉化爲 jpg 等格式,壓縮比就高得多了,我們就可以以較小的質量損失去換取較大的壓縮空間。

我的思路是這樣:把這張透明的 png 格式圖片拆分爲兩張不透明的 jpg 格式圖片。這兩張不透明的圖片,其中一張繼承 png 圖片的 rgb 通道,還有一張則僅使用 r 通道儲存 png 圖片的 a 通道。然後把這兩張圖片拼接在一起,給 WebGL 使用。由於拼接後的這張圖沒有透明度分量,所以可以使用 jpg 格式壓縮,尺寸大幅度降低。這張圖只有 41.5k,大約爲之前的 8.2%。

這張圖看上去是這樣的:

注意,前一張圖的像素尺寸是 750x571,而後一張圖的像素尺寸爲 1024x1024,這裏我並沒有通過縮小圖片的像素尺寸來進行壓縮。

此外,第二張圖看上去有些變形,這是因爲圖片尺寸爲 2 的整數次冪,WebGL 能夠方便地生成 mipmap,這對我們的使用沒有影響。

在 shader 中,我們根據像素座標從圖片中取色,注意需要從圖的上半部分和下半部分各取一個顏色,然後根據一定規則拼起來即可。

precision mediump float;

uniform vec2 uResolution;
uniform sampler2D uImage;

void main(){

vec2 st = gl_FragCoord.xy / uResolution;

vec4 c2 = texture2D(uImage, vec2(st.x, st.y*0.5)); // 取 A​lpha 通道
vec4 c1 = texture2D(uImage, vec2(st.x, st.y*0.5+0.5)); // 取 RGB 通道

gl_FragColor = vec4(c1.xyz, c2.r > 0.6 ? c2.r : 0.0);
}

png 圖片轉化爲 jpg 圖片的過程,可以很輕鬆地在瀏覽器裏操作 canvas 完成(示例),也可以藉助一些其他的工具完成。

首先,我們注意到,原頁面的背景是在紅色的漸變之上,隨機散佈着一些黃色的氛圍小碎片。紅色漸變背景和這些小碎片全部畫在一張靜態 jpg 圖片上,如下圖(1.原圖)所示。

在復刻前,我把原頁面用到的圖片分爲了兩類,圖案(pattern)性質和圖片(image)性質。Pattern 性質的圖片,本身並不傳遞信息,通常用作底紋,氛圍等場景;而 image 性質的圖片則是信息的載體。

這張圖片明顯是 pattern 性質的,這類圖片往往尺寸大,體積也較大(尤其是半透明圖案)。其實,這些圖案完全可以用 shader 「手繪」出來,這樣就不用去加載此圖片了。
用 shader 繪製圖案的另一個好處是,圖案可以有規律地動起來。在這個例子中,如果碎片能像天女散花一般灑下來,那就太棒了,對吧?但是熟悉前端動畫的同學,一定會想到,這麼多粒子組成的動畫,如果用純 CSS 或者 canvas 2d 來做的話,性能肯定好不了,粒子越多,動畫的性能越差。用 WebGL 和 shader 來做粒子動畫則不會因爲粒子數量的增多而導致性能變差。

我們來看看如何用 shader 繪製這些碎片。

vec2 random2(vec2 st){
st = vec2( dot(st,vec2(127.1,311.7)),
dot(st,vec2(269.5,183.3)));
st = -1.0 + 2.0*fract(sin(st)*43758.5453123);
return st;
}


float noise2(vec2 ist, vec2 fst){
vec2 g1 = random2(ist+vec2(0.0, 0.0));
vec2 g2 = random2(ist+vec2(1.0, 0.0));
vec2 g3 = random2(ist+vec2(0.0, 1.0));
vec2 g4 = random2(ist+vec2(1.0, 1.0));

vec2 f1 = fst - vec2(0.0, 0.0);
vec2 f2 = fst - vec2(1.0, 0.0);
vec2 f3 = fst - vec2(0.0, 1.0);
vec2 f4 = fst - vec2(1.0, 1.0);

float p1 = dot(g1, f1);
float p2 = dot(g2, f2);
float p3 = dot(g3, f3);
float p4 = dot(g4, f4);

fst = smoothstep(0.0, 1.0, fst);

float p = mix(
mix(p1, p2, fst.x),
mix(p3, p4, fst.x),
fst.y
);

return p;
}


float inFrag(){
vec2 st = gl_FragCoord.xy / uResolution.xx;
st = st * 60.0;
float res = noise2(floor(st), fract(st));
return res;
}

void main(){
float pct = inFrag();
gl_FragColor = vec4(vec3(pct), 1.0);
}

首先我們要藉助一個梯度噪聲函數 noise2(參考此教程),對每個像素而言,把像素座標輸入,這個函數則會輸出一個灰度值。此函數輸出的圖像大致如上圖 (2.梯度噪聲)所示。
如果你對諸如「噪聲函數」的原理感到陌生,其實也沒太大關係。你可以在社區找到大量各種各樣的開箱即用的功能函數,只需要知道它們的效果是什麼,而不必太拘泥於其內部的原理。
顯然,圖 2.梯度噪聲 和我們設想的還有差距。接下來,我們用一個篩子把亮度大於某個閾值的點篩出來:

function initFrag(){
...
res = step(0.5, res);
return res;
}

這樣,用 step 函數直接把大於 0.5 的點篩出來。可是這樣做容易產生鋸齒,爲了使碎片的邊緣比較平滑,所以我們用 smoothsStep 函數進行截取。

res = smoothstep(0.35, 0.5, res);

這樣,我們就得到了圖 3.拉伸的結果。

圖 3 只是一張灰度圖,我們使用這個灰度混合紅色和黃色,使之得到一張彩色的圖。

vec3 bgColor(){
float y = gl_FragCoord.y / uResolution.y;
vec3 c1 = vec3(0.96, 0.02, 0.16);
vec3 c2 = vec3(0.96, 0.25, 0.21);
return mix(c1, c2, y);
}

void main(){
...

vec3 cRed = bgColor();
vec3 cYello = vec3(0.96, 0.70, 0.26);

gl_FragColor = vec4(mix(cRed, cYello, pct), 1.0);
}

這裏 bgColor 方法返回紅色,由於紅色背景仍然是有一點垂直漸變色效果的,所以這裏也要額外用兩種不同的紅色進行混合(混合係數和像素座標的 Y 值相關),處理成漸變色。

此時我們的結果和原圖的意圖還有些不同:

  • 原圖中,頁面下半部分的碎片比較透明度,越往頁面下方,碎片就越透明(融入了紅色背景)。
  • 原圖中,中間圈圈部分(即紅色窗格佔據的部分)沒有碎片。
  • 原圖中,碎片的分佈沒有這麼均勻,常有一小塊區域完全沒有碎片的情況,似乎有一種尺寸更大的隨機變量在影響。

從以上三點出發,我們製作了 3 個通道,並依次疊加(如圖 5,圖 6,圖 7)所示,最終得到如 圖 7 所示。將疊加後的結果與圖 3 進行疊加,也就是說,圖 3 中被篩出的點,如果在圖 7 中是較暗的,則也會被降低亮度。再使用這一步的結果進行混色,最終得到圖 8 的效果。

void main() {

float pct = inFrag();
pct = min(pct, yFactor());
pct = min(pct, rFactor());
pct = min(pct, mFactor());

...
}

下面,我們來使碎片動起來(灑下來)。在生成碎片的時候,傳入噪音函數的座標數據中,加上和時間有關的偏移量:

float inFrag(){
vec2 st = gl_FragCoord.xy / uResolution.xx;
st = st * 60.0;
st.y += uTime * 2.0; // 增加與時間相關的偏移量
float res = noise2(floor(st), fract(st));
res = smoothstep(0.35, 0.5, res);
return res;
}

最後,爲了更出色的效果,我這裏做了兩個碎層碎片,兩層碎片具有不同的下落速度,形成一些視差效果。

void main() {

float pct = inFrag();
pct = min(pct, yFactor());
pct = min(pct, rFactor());
pct = min(pct, mFactor());

float pct2 = inFrag2();
pct2 = min(pct2, yFactor());
pct2 = min(pct2, rFactor());
pct2 = min(pct2, mFactor());

pct = max(pct, pct2);

...
}

這樣,就在完全不依賴外部資源的情況下,僅用 shader 直接繪製,製作出了氛圍碎片的效果。

原頁面中有一個圓形的窗格,這個窗格也是畫在一張透明圖片上。不知讀者是否注意到,在復刻後的頁面中,這個窗格是用 shader 直接畫出來的。

實際上,這種複雜程度的窗格,也可以歸爲圖案(pattern)一類,shader 是完全可以直接畫出來的。下面,我們就來看看用 shader 如何來畫窗格。
窗格是由線組成的,其基本單元是線。首先我們看一下是如何畫線的:

// 繪製線的函數 veins
float line(float e, float w, float d, float p){
float e1 = e - w/2.0;
float e2 = e + w/2.0;
return smoothstep(e1 - d / 2.0, e1 + d / 2.0, p) *
smoothstep(e2 + d / 2.0, e2 - d / 2.0, p);
}

// 繪製網格
vec3 veins(){
float r = uResolution.x * 0.4;
vec2 center = vec2(uResolution.x/2.0, uResolution.y-r-5.0);
vec2 st = gl_FragCoord.xy - center;
st /= uResolution.x * 0.5;

float p = line(0.0, 0.3, 0.2, st.x);

return mix(veinsBgColor, veinsFgColor, p);
}

// 主函數
void main(){
vec3 res = veins();

gl_FragColor = vec4(vec3(res), 1.0);
}

main 函數調用 veins 函數,veins 又調用 line 函數得到一個灰度值,然後混合兩種顏色。上述程序的結果如下圖所示。

解釋一下幾個參數:p 是當前像素的 x 或 y 座標值(取決於橫線還是豎線,如果是橫線爲 y 座標值,如果爲豎線爲 x 座標值),e 則是所繪製的直線所在的座標。w 指線的寬度,而 d 指在線與非線的交界處,用來平滑的區域的寬度。

在上面的代碼中,w 取了 0.3,而 d 取了 0.2,線看上去很粗。後面,我們會把這兩個值固定在 0.035 和 0.003 上。

由於窗格圖案中包含多跟線,我們需要多次調用 line 函數,並得到一個一個灰度值。如果當前像素在「任意一個」 line 函數中返回了大於 0 的灰度值,我們就認爲這個像素是在圖案上的。換言之,我們取多次 line 函數返回的灰度值中最大的那個值,作爲最後的灰度值來計算顏色。代碼如下所示:

float maxList(float list[20]){
float res = list[0];
for(int i=0; i<20; i++){
if(list[i]>res){
res = list[i];
}
}
return res;
}

vec3 veins(){
...

float p = 0.0;
float pl[20];
pl[0] = line(0.29, 0.035, 0.003, st.x);
pl[1] = line(0.58, 0.035, 0.003, st.x);
...
pl[7] = line(-0.58, 0.035, 0.003, st.y);

p = maxList(pl);

...
}

我們計算了 8 根直線,得到的結果如下圖 2 所示。

拆解圖案,我們發現光有直線還不能滿足要求,還需要有射線和矩形框。同樣,我們引入射線 ray 和矩形框 box 函數。

float rayV(vec2 ep, float w, float d,  float dir, vec2 st){
float pct = line(ep.x, w, d, st.x);
if((st.y - ep.y) * dir < 0.0){
pct = 0.0;
}
return pct;
}

float rayH(vec2 ep, float w, float d, float dir, vec2 st){
float pct = line(ep.y, w, d, st.y);
if((st.x - ep.x)* dir < 0.0){
pct = 0.0;
}
return pct;
}

float box(vec2 center, float width, float height, float w, float d, vec2 st){

float l1 = line(center.x, width+w, d, st.x);
float l2 = line(center.y, height+w, d, st.y);

float inBox = l1*l2;
float plist[20];

plist[0] = line(center.x+width*0.5, w, d, st.x);
plist[1] = line(center.x-width*0.5, w, d, st.x);
plist[2] = line(center.y+height*0.5, w, d, st.y);
plist[3] = line(center.y-height*0.5, w, d, st.y);

float p = maxList(plist);
p *= inBox;
return p;
}

然後依次向圖案中增加內容,得到圖 4,圖 6 的效果。通過最終的疊加,得到了圖 7 的效果。代碼如下(不要被密密麻麻的浮點數嚇住了,其實都是一些固定的座標而已,有意義的值只有幾個,通過正負號進行組合形成圖案):

float p = 0.0;
float pl[20];
pl[0] = line(0.29, 0.035, 0.003, st.x);
pl[1] = line(0.58, 0.035, 0.003, st.x);
pl[2] = line(-0.29, 0.035, 0.003, st.x);
pl[3] = line(-0.58, 0.035, 0.003, st.x);
pl[4] = line(0.29, 0.035, 0.003, st.y);
pl[5] = line(0.58, 0.035, 0.003, st.y);
pl[6] = line(-0.29, 0.035, 0.003, st.y);
pl[7] = line(-0.58, 0.035, 0.003, st.y);

pl[8] = rayV(vec2(0.0, 0.29), 0.035, 0.003, 1.0, st);
pl[9] = rayV(vec2(0.0, -0.29), 0.035, 0.003, -1.0, st);
pl[10] = rayH(vec2(0.29, 0.0), 0.035, 0.003, 1.0, st);
pl[11] = rayH(vec2(-0.29, 0.0), 0.035, 0.003, -1.0, st);

p = maxList(pl);

float pl2[20];

pl2[0] = box(vec2(0.0, 0.0), 0.39, 0.39, 0.035, 0.003, st);

pl2[1] = box(vec2(0.29, 0.29), 0.39, 0.39, 0.035, 0.003, st);
pl2[2] = box(vec2(-0.29, 0.29), 0.39, 0.39, 0.035, 0.003, st);
pl2[3] = box(vec2(-0.29, -0.29), 0.39, 0.39, 0.035, 0.003, st);
pl2[4] = box(vec2(0.29, -0.29), 0.39, 0.39, 0.035, 0.003, st);

pl2[5] = box(vec2(0.58, 0.0), 0.39, 0.39, 0.035, 0.003, st);
pl2[6] = box(vec2(-0.58, 0.0), 0.39, 0.39, 0.035, 0.003, st);
pl2[7] = box(vec2(0.0, 0.58), 0.39, 0.39, 0.035, 0.003, st);
pl2[8] = box(vec2(0.0, -0.58), 0.39, 0.39, 0.035, 0.003, st);

pl2[9] = box(vec2(0.58, 0.58), 0.39, 0.39, 0.035, 0.003, st);
pl2[10] = box(vec2(-0.58, 0.58), 0.39, 0.39, 0.035, 0.003, st);
pl2[11] = box(vec2(-0.58, -0.58), 0.39, 0.39, 0.035, 0.003, st);
pl2[12] = box(vec2(0.58, -0.58), 0.39, 0.39, 0.035, 0.003, st);

p = max(p, maxList(pl2));

得到圖 7 的圖案後,我們還需要爲其蒙上一層陰影(可對比原圖),這樣後面裁切的時候會有一些立體感。

float shadow(){
float r = uResolution.x * 0.4;
vec2 center = vec2(uResolution.x/2.0, uResolution.y-r-5.0);
vec2 st = gl_FragCoord.xy - center;
st /= uResolution.x * 0.5;

return smoothstep(0.9, 0.3, st.y+0.5*st.x*st.x-0.1);
}

vec3 veins(){
return mix(veinsBgColor, veinsFgColor, p)*shadow();
}

這裏爲了方便,使用了一個開口朝下,中軸和 y 軸重合的拋物線(st.y + 0.5 st.x st.x - 0.1)來模擬圓形的陰影。這樣我們就得到了圖 8。

最後,原設計稿中紅色邊框和透明背景的效果,對整個圖像進行了兩次裁切。裁切掉的部分,分別用紅色和透明色來填充。依次得到圖 9 和 圖 10 的結果。圖 10 也就是最終的結果。

vec3 circle(vec3 veinsColor){

float r = uResolution.x * 0.4;
vec2 center = vec2(uResolution.x/2.0, uResolution.y-r-5.0);

vec2 dxy = gl_FragCoord.xy - center;
float dist = sqrt(dxy.x*dxy.x+dxy.y*dxy.y);

float p = dist/r;
p = smoothstep(0.95, 0.96, p);

return mix(veinsColor, borderColor, p);
}

vec4 clip(vec3 color){
float r = uResolution.x * 0.4;
vec2 center = vec2(uResolution.x/2.0, uResolution.y-r-5.0);

vec2 dxy = gl_FragCoord.xy - center;
float dist = sqrt(dxy.x*dxy.x+dxy.y*dxy.y);

float p = smoothstep(1.0, 1.02, dist/r);

return vec4(color, 1.0-p);
}


void main(){
vec3 res = veins();
res = circle(res);

gl_FragColor = clip(res);
}

通過上面三個例子,可以看到,合理地使用 WebGL 可以對頁面進行精雕細琢的優化,可以減少對圖片的依賴,避免使用大尺寸的透明圖層,可以做一些全局性/背景性的動畫效果。由於 WebGL 是給了開發者「逐個像素」進行着色的能力,開發者可以非常靈活地使用 shader 來做事情。所以說,靈活地使用 shader ,可以幫助你把頁面變得更小,更炫,更快。

其實復刻後的頁面裏還有一些其他用 shader 完成的小玩意兒,比如底部 loading bar 的動態顏色漸變,以及中部文字「魅族手機祝你新春快樂」上掠過的高光,因爲點比較小,用到的技術也比較簡單,就不再詳細介紹了。

(完)

題圖: https://unsplash.com/photos/NFs6dRTBgaM By @Ferdinand Stöhr

活用 Shader,讓你的頁面更小,更炫,更快

可編程着色器(shader)是運行在 GPU

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章