内容简介:可编程着色器(shader)是运行在 GPU 中的程序,是现代图形渲染技术的基础。shader 赋予了开发者「逐像素着色」的能力。桌面/移动设备的图形程序 API 诸如 OpenGL,OpenGL ES,DirectX 以及新一代的 Vulkan,shader 都是重中之重,核心中的核心。WebGL 的出现,使得在浏览器环境中渲染 3D 场景变得轻而易举。但是 WebGL 和 shader 不仅可以用来渲染 3D 场景,还可以做一些其他酷酷的事情。前两天,我用 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)); // 取 Alpha 通道 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,让你的页面更小,更炫,更快》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- PublicCMS 202004.b 发布,动态页面也支持 SSI,速度更快
- 让SpringBoot启动更快一点
- 该如何才能更快且有效的学习?
- 更快更稳定 慧荣SM2260主控测试
- 使用 WebGL 2.0 更快地渲染 3D
- MSON,让JSON序列化更快
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。