活用 Shader,让你的页面更小,更炫,更快

栏目: 编程工具 · 发布时间: 7年前

内容简介:可编程着色器(shader)是运行在 GPU 中的程序,是现代图形渲染技术的基础。shader 赋予了开发者「逐像素着色」的能力。桌面/移动设备的图形程序 API 诸如 OpenGL,OpenGL ES,DirectX 以及新一代的 Vulkan,shader 都是重中之重,核心中的核心。WebGL 的出现,使得在浏览器环境中渲染 3D 场景变得轻而易举。但是 WebGL 和 shader 不仅可以用来渲染 3D 场景,还可以做一些其他酷酷的事情。前两天,我用 shader 技术改造 / 复刻了之前开发的

活用 Shader,让你的页面更小,更炫,更快

可编程着色器(shader)是运行在 GPU 中的程序,是现代图形渲染技术的基础。shader 赋予了开发者「逐像素着色」的能力。桌面/移动设备的图形程序 API 诸如 OpenGL,OpenGL ES,DirectX 以及新一代的 Vulkan,shader 都是重中之重,核心中的核心。

WebGL 的出现,使得在浏览器环境中渲染 3D 场景变得轻而易举。但是 WebGL 和 shader 不仅可以用来渲染 3D 场景,还可以做一些其他酷酷的事情。前两天,我用 shader 技术改造 / 复刻了之前开发的一个业务页面,颇有心得和启发,不妨记录下来。

广告:在 GCanvas 的帮助下,前端开发可以在 Weex,RN 等 Hybrid 环境中使用本文中用到的技术。详情见 GCanvas

先看一下效果: 链接

活用 Shader,让你的页面更小,更炫,更快

左侧是原页面, 地址 ;右侧是用 Shader 复刻后的页面, 地址

这其实是 2018 年春晚项目的一个活动页面,页面结构非常简单。这个页面当时是我完成的,所以现在复刻起来熟悉一些。

我们可以看到,复刻前的页面(后面称「原页面」)是静态的,加载了 1 个 js 文件和 6 张图片共 599K 的资源(包含一张 502K 的大尺寸透明 png 图片);而复刻后的页面上,有不少元素在动,加载了 1 个 js 文件和 4 张图片共 122K 的资源。不管是视觉效果,还是页面尺寸上的提升,都是比较明显的。

活用 Shader,让你的页面更小,更炫,更快

活用 Shader,让你的页面更小,更炫,更快

下面,我们就以这个页面为例,分析一下,使用 shader 是如何让这个页面更小,更炫,更快。

阅读后面的文本需要一些 WebGL 和 GLSL 的基础知识,之前在团内对曾做过一些培训,参加的同学应该不会有什么压力,没参加的同学,也可以稍微看下 the book of shaders 这篇教程。shader 比你想象的要简单易用,相信我。

大尺寸透明背景图

原页面存在的一个最大的问题是,有一张特别大的透明背景图。

活用 Shader,让你的页面更小,更炫,更快

这张图的体积达到了惊人的 501K,这是因为这张图是具有透明通道的 png 图片。而且由于这张图是广告内容,可能不止一张,是无法融合到背景里去的,必须透明。这时怎么优化呢?

我们知道,具有透明通道的 png 的压缩是比较困难的;而不具备透明通道的图片,我们可以把它转化为 jpg 等格式,压缩比就高得多了,我们就可以以较小的质量损失去换取较大的压缩空间。

我的思路是这样:把这张透明的 png 格式图片拆分为两张不透明的 jpg 格式图片。这两张不透明的图片,其中一张继承 png 图片的 rgb 通道,还有一张则仅使用 r 通道储存 png 图片的 a 通道。然后把这两张图片拼接在一起,给 WebGL 使用。由于拼接后的这张图没有透明度分量,所以可以使用 jpg 格式压缩,尺寸大幅度降低。这张图只有 41.5k,大约为之前的 8.2%。

这张图看上去是这样的:

活用 Shader,让你的页面更小,更炫,更快

注意,前一张图的像素尺寸是 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.原图)所示。

活用 Shader,让你的页面更小,更炫,更快

在复刻前,我把原页面用到的图片分为了两类,图案(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 直接画出来的。

活用 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 函数得到一个灰度值,然后混合两种颜色。上述程序的结果如下图所示。

活用 Shader,让你的页面更小,更炫,更快

解释一下几个参数: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 所示。

活用 Shader,让你的页面更小,更炫,更快

拆解图案,我们发现光有直线还不能满足要求,还需要有射线和矩形框。同样,我们引入射线 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,让你的页面更小,更炫,更快》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

How to Build a Billion Dollar App

How to Build a Billion Dollar App

George Berkowski / Little, Brown Book Group / 2015-4-1 / USD 24.95

Apps have changed the way we communicate, shop, play, interact and travel and their phenomenal popularity has presented possibly the biggest business opportunity in history. In How to Build a Billi......一起来看看 《How to Build a Billion Dollar App》 这本书的介绍吧!

在线进制转换器
在线进制转换器

各进制数互转换器

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

MD5 加密
MD5 加密

MD5 加密工具