本文通过模仿抖音中几种特效的实现,来讲解 GLSL 的实际应用。
前言
本文的灵感来自于《当一个 Android 开发玩抖音玩疯了之后(二)》这篇文章。
这位博主在 Android 平台上,通过自己的分析,尝试还原了抖音上的几种视频特效。他是通过「部分 GLSL 代码 + 部分 Java 代码」的方式来实现的。
读完之后,在膜拜之余,我产生了一个大胆的想法:我可不可以在 iOS 上,只通过纯 GLSL 的编写,来实现类似的效果呢?
很好的想法,不过,由于抖音的特效是基于视频的滤镜,我们在这之前只讲到了关于图片的渲染,如果马上跳跃到视频的部分,好像有点超纲了。
于是,我又有了一个更大胆的想法:我可不可以在 iOS 上,只通过纯 GLSL 的编写,在静态的图片上,实现类似的效果呢?
这样的话,我们就可以把更多的注意力放在 GLSL 本身,而不是视频的采集和输出上面。
于是,就有了这篇文章。为了无缝地过渡,我会沿用之前GLSL 渲染的例子,只改变 Shader 部分的代码,来尝试还原那篇文章中实现的六种特效。
作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要这是一个我iOS开发交流学习群不管你是小白还是大牛欢迎入驻 ,分享BAT,阿里面试题、面试经验,讨论技术, 大家一起交流学习成长!
〇、动画
你可能会问:抖音上的特效都是动态的,要怎么把动态的效果,加到一个静态的图片上呢?
问的好,所以第一步,我们就要让静态的图片动起来。
回想一下,我们在UIKit中实现的动画,无非就是把指令发送给CoreAnimation,然后在屏幕刷新的时候,CoreAnimation会去逐帧计算当前应该显示的图像。
这里的重点是「逐帧计算」。在 OpenGL ES 中也是类似,我们实现动画的方式,就是自己去计算每一帧应该显示的图像,然后在屏幕刷新的时候,重新渲染。
这个「逐帧计算」的过程,我们是放到 Shader 中进行的。然后我们可以通过一个表示时间的参数,在重新渲染的时候,传入当前的时间,让 Shader 计算出当前动画的进度。至于重新渲染的时机,则是依靠CADisplayLink来实现的。
具体代码大概像这样:
self.displayLink=[CADisplayLink displayLinkWithTarget:selfselector:@selector(timeAction)];[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop]forMode:NSRunLoopCommonModes];
-(void)timeAction{glUseProgram(self.program);glBindBuffer(GL_ARRAY_BUFFER,self.vertexBuffer);// 传入时间CGFloat currentTime=self.displayLink.timestamp-self.startTimeInterval;GLuint time=glGetUniformLocation(self.program,"Time");glUniform1f(time,currentTime);// 清除画布glClear(GL_COLOR_BUFFER_BIT);glClearColor(1,1,1,1);// 重绘glDrawArrays(GL_TRIANGLE_STRIP,0,4);[self.context presentRenderbuffer:GL_RENDERBUFFER];}
相应地,在 Shader 中有一个uniform修饰的Time参数:
uniform float Time;
这样 Shader 就可以通过Time来计算出当前应该显示的图像了。
一、缩放
1、最终效果
我们要实现的第一种效果是「缩放」,看起来很简单,可以通过修改顶点坐标和纹理坐标的对应关系来实现。
这是一个很基础的效果,在下面的其它特效中还会用到。修改坐标的对应关系可以通过修改顶点着色器,或者修改片段着色器来实现。 这里先讲修改顶点着色器的方式,在后面的特效中会再提一下修改片段着色器的方式。
2、代码实现
顶点着色器代码:
attribute vec4 Position;attribute vec2 TextureCoords;varying vec2 TextureCoordsVarying;uniformfloatTime;constfloatPI=3.1415926;voidmain(void){floatduration=0.6;floatmaxAmplitude=0.3;floattime=mod(Time,duration);floatamplitude=1.0+maxAmplitude*abs(sin(time*(PI/duration)));gl_Position=vec4(Position.x*amplitude,Position.y*amplitude,Position.zw);TextureCoordsVarying=TextureCoords;}
这里的duration表示一次缩放周期的时长,mod(Time, duration)表示将传入的时间转换到一个周期内,即time的范围是0 ~ 0.6,amplitude表示振幅,引入PI的目的是为了使用sin函数,将amplitude的范围控制在1.0 ~ 1.3之间,并随着时间变化。
这里放大的关键在于vec4(Position.x * amplitude, Position.y * amplitude, Position.zw),我们将顶点坐标的x和y分别乘上一个放大系数,在纹理坐标不变的情况下,就达到了拉伸的效果。
二、灵魂出窍
1、最终效果
「灵魂出窍」看上去是两个层的叠加,并且上面的那层随着时间的推移,会逐渐放大且不透明度逐渐降低。这里也用到了放大的效果,我们这次用片段着色器来实现。
2、代码实现
片段着色器代码:
precision highpfloat;uniform sampler2D Texture;varying vec2 TextureCoordsVarying;uniformfloatTime;voidmain(void){floatduration=0.7;floatmaxAlpha=0.4;floatmaxScale=1.8;floatprogress=mod(Time,duration)/duration;// 0~1floatalpha=maxAlpha*(1.0-progress);floatscale=1.0+(maxScale-1.0)*progress;floatweakX=0.5+(TextureCoordsVarying.x-0.5)/scale;floatweakY=0.5+(TextureCoordsVarying.y-0.5)/scale;vec2 weakTextureCoords=vec2(weakX,weakY);vec4 weakMask=texture2D(Texture,weakTextureCoords);vec4 mask=texture2D(Texture,TextureCoordsVarying);gl_FragColor=mask*(1.0-alpha)+weakMask*alpha;}
首先是放大的效果。关键点在于weakX和weakY的计算,比如0.5 + (TextureCoordsVarying.x - 0.5) / scale这一句的意思是,将顶点坐标对应的纹理坐标的x值到纹理中点的距离,缩小一定的比例。这次我们是改变了纹理坐标,而保持顶点坐标不变,同样达到了拉伸的效果。
然后是两层叠加的效果。通过上面的计算,我们得到了两个纹理颜色值weakMask和mask,weakMask是在mask的基础上做了放大处理。
我们将两个颜色值进行叠加需要用到一个公式:最终色 = 基色 * a% + 混合色 * (1 - a%),这个公式来自混合模式中的正常模式。
这个公式表明了一个不透明的层和一个半透明的层进行叠加,重叠部分的最终颜色值。因此,上面叠加的最终结果是mask * (1.0 - alpha) + weakMask * alpha。
三、抖动
1、最终效果
「抖动」是很经典的抖音的颜色偏移效果,其实这个效果实现起来还挺简单的。另外,除了颜色偏移,可以看到还有微弱的放大效果。
2、代码实现
片段着色器代码:
precision highpfloat;uniform sampler2D Texture;varying vec2 TextureCoordsVarying;uniformfloatTime;voidmain(void){floatduration=0.7;floatmaxScale=1.1;floatoffset=0.02;floatprogress=mod(Time,duration)/duration;// 0~1vec2 offsetCoords=vec2(offset,offset)*progress;floatscale=1.0+(maxScale-1.0)*progress;vec2 ScaleTextureCoords=vec2(0.5,0.5)+(TextureCoordsVarying-vec2(0.5,0.5))/scale;vec4 maskR=texture2D(Texture,ScaleTextureCoords+offsetCoords);vec4 maskB=texture2D(Texture,ScaleTextureCoords-offsetCoords);vec4 mask=texture2D(Texture,ScaleTextureCoords);gl_FragColor=vec4(maskR.r,mask.g,maskB.b,mask.a);}
这里的放大和上面类似,我们主要看一下颜色偏移。颜色偏移是对三个颜色通道进行分离,并且给红色通道和蓝色通道添加了不同的位置偏移,代码很容易看懂。
四、闪白
1、最终效果
「闪白」其实看起来一点儿也不酷炫,而且看久了还容易被闪瞎。这个效果实现起来也十分简单,无非就是叠加一个白色层,然后白色层的透明度随着时间不断地变化。
2、代码实现
片段着色器代码:
precision highpfloat;uniform sampler2D Texture;varying vec2 TextureCoordsVarying;uniformfloatTime;constfloatPI=3.1415926;voidmain(void){floatduration=0.6;floattime=mod(Time,duration);vec4 whiteMask=vec4(1.0,1.0,1.0,1.0);floatamplitude=abs(sin(time*(PI/duration)));vec4 mask=texture2D(Texture,TextureCoordsVarying);gl_FragColor=mask*(1.0-amplitude)+whiteMask*amplitude;}
在上面「灵魂出窍」的例子中,我们已经知道了如何实现两个层的叠加。这里我们只需要创建一个白色的层whiteMask,然后根据当前的透明度来计算最终的颜色值即可。
五、毛刺
1、最终效果
终于有了一个稍微复杂一点的效果,「毛刺」看上去是「撕裂 + 微弱的颜色偏移」。颜色偏移我们在上面已经实现,这里主要是讲解撕裂的效果。
具体的思路是,我们让每一行像素随机偏移-1 ~ 1的距离(这里的-1 ~ 1是对于纹理坐标来说的),但是如果整个画面都偏移比较大的值,那我们可能都看不出原来图像的样子。所以我们的逻辑是,设定一个阈值,小于这个阈值才进行偏移,超过这个阈值则乘上一个缩小系数。
则最终呈现的效果是:绝大部分的行都会进行微小的偏移,只有少量的行会进行较大偏移。
2、代码实现
片段着色器代码:
precision highpfloat;uniform sampler2D Texture;varying vec2 TextureCoordsVarying;uniformfloatTime;constfloatPI=3.1415926;floatrand(floatn){returnfract(sin(n)*43758.5453123);}voidmain(void){floatmaxJitter=0.06;floatduration=0.3;floatcolorROffset=0.01;floatcolorBOffset=-0.025;floattime=mod(Time,duration*2.0);floatamplitude=max(sin(time*(PI/duration)),0.0);floatjitter=rand(TextureCoordsVarying.y)*2.0-1.0;// -1~1boolneedOffset=abs(jitter)<maxJitter*amplitude;floattextureX=TextureCoordsVarying.x+(needOffset?jitter:(jitter*amplitude*0.006));vec2 textureCoords=vec2(textureX,TextureCoordsVarying.y);vec4 mask=texture2D(Texture,textureCoords);vec4 maskR=texture2D(Texture,textureCoords+vec2(colorROffset*amplitude,0.0));vec4 maskB=texture2D(Texture,textureCoords+vec2(colorBOffset*amplitude,0.0));gl_FragColor=vec4(maskR.r,mask.g,maskB.b,mask.a);}
上面提到的像素随机偏移需要用到随机数,可惜 GLSL 里并没有内置的随机函数,所以我们需要自己实现一个。
这个float rand(float n)的实现看上去很神奇,它其实是来自这里,江湖人称「噪声函数」。
它其实是一个伪随机函数,本质上是一个 Hash 函数。但在这里我们可以把它当成随机函数来使用,它的返回值范围是0 ~ 1。如果你对这个函数想了解更多的话可以看这里。
六、幻觉
1、最终效果
「幻觉」这个效果有点一言难尽,因为其实看上去并不是很像。原来的效果是基于视频上一帧的结果去合成,静态的图片很难模拟出这种情况。不管怎么说,既然已经尽力,不像就不像吧,下面讲一下我的实现思路。
可以看出这个效果是残影和颜色偏移的叠加。
残影的效果还好,在移动的过程中,每经过一段时间间隔,根据当前的位置去创建一个新层,并且新层的不透明度随着时间逐渐减弱。于是在一个移动周期内,可以看到很多透明度不同的层叠加在一起,从而形成残影的效果。
然后是这个颜色偏移。我们可以看到,物体移动的过程是蓝色在前面,红色在后面。所以整个过程可以理解成:在移动的过程中,每间隔一段时间,遗失了一部分红色通道的值在原来的位置,并且这部分红色通道的值,随着时间偏移,会逐渐恢复。
2、代码实现
片段着色器代码:
precision highpfloat;uniform sampler2D Texture;varying vec2 TextureCoordsVarying;uniformfloatTime;constfloatPI=3.1415926;constfloatduration=2.0;vec4getMask(floattime,vec2 textureCoords,floatpadding){vec2 translation=vec2(sin(time*(PI*2.0/duration)),cos(time*(PI*2.0/duration)));vec2 translationTextureCoords=textureCoords+padding*translation;vec4 mask=texture2D(Texture,translationTextureCoords);returnmask;}floatmaskAlphaProgress(floatcurrentTime,floathideTime,floatstartTime){floattime=mod(duration+currentTime-startTime,duration);returnmin(time,hideTime);}voidmain(void){floattime=mod(Time,duration);floatscale=1.2;floatpadding=0.5*(1.0-1.0/scale);vec2 textureCoords=vec2(0.5,0.5)+(TextureCoordsVarying-vec2(0.5,0.5))/scale;floathideTime=0.9;floattimeGap=0.2;floatmaxAlphaR=0.5;// max RfloatmaxAlphaG=0.05;// max GfloatmaxAlphaB=0.05;// max Bvec4 mask=getMask(time,textureCoords,padding);floatalphaR=1.0;// RfloatalphaG=1.0;// GfloatalphaB=1.0;// Bvec4 resultMask=vec4(0,0,0,0);for(floatf=0.0;f<duration;f+=timeGap){floattmpTime=f;vec4 tmpMask=getMask(tmpTime,textureCoords,padding);floattmpAlphaR=maxAlphaR-maxAlphaR*maskAlphaProgress(time,hideTime,tmpTime)/hideTime;floattmpAlphaG=maxAlphaG-maxAlphaG*maskAlphaProgress(time,hideTime,tmpTime)/hideTime;floattmpAlphaB=maxAlphaB-maxAlphaB*maskAlphaProgress(time,hideTime,tmpTime)/hideTime;resultMask+=vec4(tmpMask.r*tmpAlphaR,tmpMask.g*tmpAlphaG,tmpMask.b*tmpAlphaB,1.0);alphaR-=tmpAlphaR;alphaG-=tmpAlphaG;alphaB-=tmpAlphaB;}resultMask+=vec4(mask.r*alphaR,mask.g*alphaG,mask.b*alphaB,1.0);gl_FragColor=resultMask;}
从代码的行数可以看出,这个效果应该是里面最复杂的。为了实现残影,我们先让图片随时间做圆周运动。
vec4 getMask(float time, vec2 textureCoords, float padding)这个函数可以计算出,在某个时刻图片的具体位置。通过它我们可以每经过一段时间,去生成一个新的层。
float maskAlphaProgress(float currentTime, float hideTime, float startTime)这个函数可以计算出,某个时刻创建的层,在当前时刻的透明度。
maxAlphaR、maxAlphaG、maxAlphaB分别指定了新层初始的三个颜色通道的透明度。因为最终的效果是残留红色,所以主要保留了红色通道的值。
然后是叠加,和两层叠加的情况类似,这里通过for循环来累加每一层的每个通道乘上自身的透明度的值,算出最终的颜色值resultMask。
注:在 iOS 的模拟器上,只能用 CPU 来模拟 GPU 的功能。所以在模拟器上运行上面的代码时,可能会十分卡顿。尤其是最后这个效果,由于计算量太大,亲测模拟器显示不出来。因此如果要跑代码,最好使用真机运行。
源码
请到GitHub上查看完整代码。
参考
作者:一意孤行的程序猿
链接:https://www.jianshu.com/p/bdeda0b9864e
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
网友评论