本文通過模仿抖音中幾種特效的實現,來講解 GLSL 的實際應用。
前言
本文的靈感來自於 《當一個 Android 開發玩抖音玩瘋了之後(二)》 這篇文章。
這位博主在 Android 平臺上,通過自己的分析,嘗試還原了抖音上的幾種視訊特效。他是通過「部分 GLSL 程式碼 + 部分 Java 程式碼」的方式來實現的。
讀完之後,在膜拜之餘,我產生了一個大膽的想法:我可不可以在 iOS 上,只通過純 GLSL 的編寫,來實現類似的效果呢?
很好的想法,不過,由於抖音的特效是基於視訊的濾鏡,我們在這之前只講到了關於圖片的渲染,如果馬上跳躍到視訊的部分,好像有點超綱了。
於是,我又有了一個更大膽的想法:我可不可以在 iOS 上,只通過純 GLSL 的編寫,在靜態的圖片上,實現類似的效果呢?
這樣的話,我們就可以把更多的注意力放在 GLSL 本身,而不是視訊的採集和輸出上面。
於是,就有了這篇文章。為了無縫地過渡,我會沿用之前 GLSL 渲染的例子 ,只改變 Shader 部分的程式碼,來嘗試還原那篇文章中實現的六種特效。
〇、動畫
你可能會問:抖音上的特效都是動態的,要怎麼把動態的效果,加到一個靜態的圖片上呢?
問的好,所以第一步,我們就要讓靜態的圖片動起來。
回想一下,我們在 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
來計算出當前應該顯示的影象了。
一、縮放
1、最終效果
我們要實現的第一種效果是「縮放」,看起來很簡單,可以通過修改頂點座標和紋理座標的對應關係來實現。
這是一個很基礎的效果,在下面的其它特效中還會用到。修改座標的對應關係可以通過修改頂點著色器,或者修改片段著色器來實現。 這裡先講修改頂點著色器的方式,在後面的特效中會再提一下修改片段著色器的方式。
2、程式碼實現
頂點著色器程式碼:
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
分別乘上一個放大係數,在紋理座標不變的情況下,就達到了拉伸的效果。
二、靈魂出竅
1、最終效果
「靈魂出竅」看上去是兩個層的疊加,並且上面的那層隨著時間的推移,會逐漸放大且不透明度逐漸降低。這裡也用到了放大的效果,我們這次用片段著色器來實現。
2、程式碼實現
片段著色器程式碼:
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
。
三、抖動
1、最終效果
「抖動」是很經典的抖音的顏色偏移效果,其實這個效果實現起來還挺簡單的。另外,除了顏色偏移,可以看到還有微弱的放大效果。
2、程式碼實現
片段著色器程式碼:
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);
}
複製程式碼
這裡的放大和上面類似,我們主要看一下顏色偏移。顏色偏移是對三個顏色通道進行分離,並且給紅色通道和藍色通道新增了不同的位置偏移,程式碼很容易看懂。
四、閃白
1、最終效果
「閃白」其實看起來一點兒也不酷炫,而且看久了還容易被閃瞎。這個效果實現起來也十分簡單,無非就是疊加一個白色層,然後白色層的透明度隨著時間不斷地變化。
2、程式碼實現
片段著色器程式碼:
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 ~ 1
是對於紋理座標來說的),但是如果整個畫面都偏移比較大的值,那我們可能都看不出原來影象的樣子。所以我們的邏輯是,設定一個閾值,小於這個閾值才進行偏移,超過這個閾值則乘上一個縮小系數。
則最終呈現的效果是:絕大部分的行都會進行微小的偏移,只有少量的行會進行較大偏移。
2、程式碼實現
片段著色器程式碼:
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
。如果你對這個函式想了解更多的話可以看 這裡 。
六、幻覺
1、最終效果
「幻覺」這個效果有點一言難盡,因為其實看上去並不是很像。原來的效果是基於視訊上一幀的結果去合成,靜態的圖片很難模擬出這種情況。不管怎麼說,既然已經盡力,不像就不像吧,下面講一下我的實現思路。
可以看出這個效果是殘影和顏色偏移的疊加。
殘影的效果還好,在移動的過程中,每經過一段時間間隔,根據當前的位置去建立一個新層,並且新層的不透明度隨著時間逐漸減弱。於是在一個移動週期內,可以看到很多透明度不同的層疊加在一起,從而形成殘影的效果。
然後是這個顏色偏移。我們可以看到,物體移動的過程是藍色在前面,紅色在後面。所以整個過程可以理解成:在移動的過程中,每間隔一段時間,遺失了一部分紅色通道的值在原來的位置,並且這部分紅色通道的值,隨著時間偏移,會逐漸恢復。
2、程式碼實現
片段著色器程式碼:
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;
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 實現抖音特效