本文经过模仿抖音中几种特效的实现,来说解 GLSL 的实际应用。html
本文的灵感来自于 《当一个 Android 开发玩抖音玩疯了以后(二)》 这篇文章。ios
这位博主在 Android 平台上,经过本身的分析,尝试还原了抖音上的几种视频特效。他是经过「部分 GLSL 代码 + 部分 Java 代码」的方式来实现的。git
读完以后,在膜拜之余,我产生了一个大胆的想法:我可不能够在 iOS 上,只经过纯 GLSL 的编写,来实现相似的效果呢?github
很好的想法,不过,因为抖音的特效是基于视频的滤镜,咱们在这以前只讲到了关于图片的渲染,若是立刻跳跃到视频的部分,好像有点超纲了。ide
因而,我又有了一个更大胆的想法:我可不能够在 iOS 上,只经过纯 GLSL 的编写,在静态的图片上,实现相似的效果呢?函数
这样的话,咱们就能够把更多的注意力放在 GLSL 自己,而不是视频的采集和输出上面。oop
因而,就有了这篇文章。为了无缝地过渡,我会沿用以前 GLSL 渲染的例子 ,只改变 Shader 部分的代码,来尝试还原那篇文章中实现的六种特效。动画
你可能会问:抖音上的特效都是动态的,要怎么把动态的效果,加到一个静态的图片上呢?ui
问的好,因此第一步,咱们就要让静态的图片动起来。spa
回想一下,咱们在 UIKit
中实现的动画,无非就是把指令发送给 CoreAnimation
,而后在屏幕刷新的时候,CoreAnimation
会去逐帧计算当前应该显示的图像。
这里的重点是「逐帧计算」。在 OpenGL ES 中也是相似,咱们实现动画的方式,就是本身去计算每一帧应该显示的图像,而后在屏幕刷新的时候,从新渲染。
这个「逐帧计算」的过程,咱们是放到 Shader 中进行的。而后咱们能够经过一个表示时间的参数,在从新渲染的时候,传入当前的时间,让 Shader 计算出当前动画的进度。至于从新渲染的时机,则是依靠 CADisplayLink
来实现的。
具体代码大概像这样:
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@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
来计算出当前应该显示的图像了。
一、最终效果
咱们要实现的第一种效果是「缩放」,看起来很简单,能够经过修改顶点坐标和纹理坐标的对应关系来实现。
这是一个很基础的效果,在下面的其它特效中还会用到。修改坐标的对应关系能够经过修改顶点着色器,或者修改片断着色器来实现。 这里先讲修改顶点着色器的方式,在后面的特效中会再提一下修改片断着色器的方式。
二、代码实现
顶点着色器代码:
attribute vec4 Position; attribute vec2 TextureCoords; varying vec2 TextureCoordsVarying; uniform float Time; const float PI = 3.1415926; void main (void) { float duration = 0.6; float maxAmplitude = 0.3; float time = mod(Time, duration); float amplitude = 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
分别乘上一个放大系数,在纹理坐标不变的状况下,就达到了拉伸的效果。
一、最终效果
「灵魂出窍」看上去是两个层的叠加,而且上面的那层随着时间的推移,会逐渐放大且不透明度逐渐下降。这里也用到了放大的效果,咱们此次用片断着色器来实现。
二、代码实现
片断着色器代码:
precision highp float; uniform sampler2D Texture; varying vec2 TextureCoordsVarying; uniform float Time; void main (void) { float duration = 0.7; float maxAlpha = 0.4; float maxScale = 1.8; float progress = mod(Time, duration) / duration; // 0~1 float alpha = maxAlpha * (1.0 - progress); float scale = 1.0 + (maxScale - 1.0) * progress; float weakX = 0.5 + (TextureCoordsVarying.x - 0.5) / scale; float weakY = 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
。
一、最终效果
「抖动」是很经典的抖音的颜色偏移效果,其实这个效果实现起来还挺简单的。另外,除了颜色偏移,能够看到还有微弱的放大效果。
二、代码实现
片断着色器代码:
precision highp float; uniform sampler2D Texture; varying vec2 TextureCoordsVarying; uniform float Time; void main (void) { float duration = 0.7; float maxScale = 1.1; float offset = 0.02; float progress = mod(Time, duration) / duration; // 0~1 vec2 offsetCoords = vec2(offset, offset) * progress; float scale = 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); }
这里的放大和上面相似,咱们主要看一下颜色偏移。颜色偏移是对三个颜色通道进行分离,而且给红色通道和蓝色通道添加了不一样的位置偏移,代码很容易看懂。
一、最终效果
「闪白」其实看起来一点儿也不酷炫,并且看久了还容易被闪瞎。这个效果实现起来也十分简单,无非就是叠加一个白色层,而后白色层的透明度随着时间不断地变化。
二、代码实现
片断着色器代码:
precision highp float; uniform sampler2D Texture; varying vec2 TextureCoordsVarying; uniform float Time; const float PI = 3.1415926; void main (void) { float duration = 0.6; float time = mod(Time, duration); vec4 whiteMask = vec4(1.0, 1.0, 1.0, 1.0); float amplitude = abs(sin(time * (PI / duration))); vec4 mask = texture2D(Texture, TextureCoordsVarying); gl_FragColor = mask * (1.0 - amplitude) + whiteMask * amplitude; }
在上面「灵魂出窍」的例子中,咱们已经知道了如何实现两个层的叠加。这里咱们只须要建立一个白色的层 whiteMask
,而后根据当前的透明度来计算最终的颜色值便可。
一、最终效果
终于有了一个稍微复杂一点的效果,「毛刺」看上去是「撕裂 + 微弱的颜色偏移」。颜色偏移咱们在上面已经实现,这里主要是讲解撕裂的效果。
具体的思路是,咱们让每一行像素随机偏移 -1 ~ 1
的距离(这里的 -1 ~ 1
是对于纹理坐标来讲的),可是若是整个画面都偏移比较大的值,那咱们可能都看不出原来图像的样子。因此咱们的逻辑是,设定一个阈值,小于这个阈值才进行偏移,超过这个阈值则乘上一个缩小系数。
则最终呈现的效果是:绝大部分的行都会进行微小的偏移,只有少许的行会进行较大偏移。
二、代码实现
片断着色器代码:
precision highp float; uniform sampler2D Texture; varying vec2 TextureCoordsVarying; uniform float Time; const float PI = 3.1415926; float rand(float n) { return fract(sin(n) * 43758.5453123); } void main (void) { float maxJitter = 0.06; float duration = 0.3; float colorROffset = 0.01; float colorBOffset = -0.025; float time = mod(Time, duration * 2.0); float amplitude = max(sin(time * (PI / duration)), 0.0); float jitter = rand(TextureCoordsVarying.y) * 2.0 - 1.0; // -1~1 bool needOffset = abs(jitter) < maxJitter * amplitude; float textureX = 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
。若是你对这个函数想了解更多的话能够看 这里 。
一、最终效果
「幻觉」这个效果有点一言难尽,由于其实看上去并非很像。原来的效果是基于视频上一帧的结果去合成,静态的图片很难模拟出这种状况。无论怎么说,既然已经尽力,不像就不像吧,下面讲一下个人实现思路。
能够看出这个效果是残影和颜色偏移的叠加。
残影的效果还好,在移动的过程当中,每通过一段时间间隔,根据当前的位置去建立一个新层,而且新层的不透明度随着时间逐渐减弱。因而在一个移动周期内,能够看到不少透明度不一样的层叠加在一块儿,从而造成残影的效果。
而后是这个颜色偏移。咱们能够看到,物体移动的过程是蓝色在前面,红色在后面。因此整个过程能够理解成:在移动的过程当中,每间隔一段时间,遗失了一部分成色通道的值在原来的位置,而且这部分成色通道的值,随着时间偏移,会逐渐恢复。
二、代码实现
片断着色器代码:
precision highp float; uniform sampler2D Texture; varying vec2 TextureCoordsVarying; uniform float Time; const float PI = 3.1415926; const float duration = 2.0; vec4 getMask(float time, vec2 textureCoords, float padding) { 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); return mask; } float maskAlphaProgress(float currentTime, float hideTime, float startTime) { float time = mod(duration + currentTime - startTime, duration); return min(time, hideTime); } void main (void) { float time = mod(Time, duration); float scale = 1.2; float padding = 0.5 * (1.0 - 1.0 / scale); vec2 textureCoords = vec2(0.5, 0.5) + (TextureCoordsVarying - vec2(0.5, 0.5)) / scale; float hideTime = 0.9; float timeGap = 0.2; float maxAlphaR = 0.5; // max R float maxAlphaG = 0.05; // max G float maxAlphaB = 0.05; // max B vec4 mask = getMask(time, textureCoords, padding); float alphaR = 1.0; // R float alphaG = 1.0; // G float alphaB = 1.0; // B vec4 resultMask = vec4(0, 0, 0, 0); for (float f = 0.0; f < duration; f += timeGap) { float tmpTime = f; vec4 tmpMask = getMask(tmpTime, textureCoords, padding); float tmpAlphaR = maxAlphaR - maxAlphaR * maskAlphaProgress(time, hideTime, tmpTime) / hideTime; float tmpAlphaG = maxAlphaG - maxAlphaG * maskAlphaProgress(time, hideTime, tmpTime) / hideTime; float tmpAlphaB = 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 上查看完整代码。
获取更佳的阅读体验,请访问原文地址【Lyman's Blog】在 iOS 中使用 GLSL 实现抖音特效