在 iOS 中使用 GLSL 實現抖音特效

本文通過模仿抖音中幾種特效的實現,來講解 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.6amplitude 表示振幅,引入 PI 的目的是為了使用 sin 函數,將 amplitude 的范圍控制在 1.0 ~ 1.3 之間,并隨著時間變化。

這里放大的關鍵在于 vec4(Position.x * amplitude, Position.y * amplitude, Position.zw) ,我們將頂點坐標的 xy 分別乘上一個放大系數,在紋理坐標不變的情況下,就達到了拉伸的效果。

二、靈魂出竅

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;
}

首先是放大的效果。關鍵點在于 weakXweakY 的計算,比如 0.5 + (TextureCoordsVarying.x - 0.5) / scale 這一句的意思是,將頂點坐標對應的紋理坐標的 x 值到紋理中點的距離,縮小一定的比例。這次我們是改變了紋理坐標,而保持頂點坐標不變,同樣達到了拉伸的效果。

然后是兩層疊加的效果。通過上面的計算,我們得到了兩個紋理顏色值 weakMaskmaskweakMask 是在 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 = 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) 這個函數可以計算出,某個時刻創建的層,在當前時刻的透明度。

maxAlphaRmaxAlphaGmaxAlphaB 分別指定了新層初始的三個顏色通道的透明度。因為最終的效果是殘留紅色,所以主要保留了紅色通道的值。

然后是疊加,和兩層疊加的情況類似,這里通過 for 循環來累加每一層的每個通道乘上自身的透明度的值,算出最終的顏色值 resultMask

注: 在 iOS 的模擬器上,只能用 CPU 來模擬 GPU 的功能。所以在模擬器上運行上面的代碼時,可能會十分卡頓。尤其是最后這個效果,由于計算量太大,親測模擬器顯示不出來。因此如果要跑代碼,最好使用真機運行。

源碼

請到 GitHub 上查看完整代碼。

參考

獲取更佳的閱讀體驗,請訪問原文地址【Lyman's Blog】在 iOS 中使用 GLSL 實現抖音特效

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,606評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,582評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,540評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,028評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,801評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,223評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,294評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,442評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,976評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,800評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,996評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,543評論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,233評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,926評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,702評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,991評論 2 374