寫 Shader 轉場的幾點思考
轉場效果在視訊編輯工具中最為常見,在兩段視訊或影象之間增加一個「過渡」的效果,可以讓整個過程更佳柔滑自然。常見的轉場如漸變過渡、旋轉、擦除等(下圖為 iMovie 自帶轉場):

而且現在很多視訊 App 中也自帶了影集功能,你可以選擇不同的轉場來製作出動態影集:

而在 WebGL 實現轉場,相比起編輯器有很大的不同,這些不同帶來了一些思考:
一、材質切換時機
在之前這篇文章中提到了兩張材質的切換,但一般影集都會大於兩張圖片,如何讓整個切換能夠迴圈且無感知?這裡通過一個簡單到動畫來示例:

簡單解釋下,假設我們的轉場效果是從右往左切換(正如動圖所示),切換的時機就是每輪動畫的結束,對 u_Sampler0
和 u_Sampler1
進行重新賦值,每輪動畫的第一張圖就是上一輪動畫的下一張圖,這種瞬間的賦值會讓整個動畫無變化感知,從而實現不同材質的迴圈,並且不會佔用 WebGL 中太多的紋理空間(只需要兩個),這種方式也是來自於 Web 端 Slider 的編寫經驗。
相關程式碼如下:
// 更換材質 function changeTexture(gl, imgList, count) { var texture0 = gl.createTexture(); var texture1 = gl.createTexture(); if (!texture0 && !texture1) { console.log('Failed to create the texture object'); return false; } var u_Sampler0 = gl.getUniformLocation(gl.program, 'u_Sampler0'); if (!u_Sampler0) { console.log('Failed to get the storage location of u_Sampler0'); return false; } var u_Sampler1 = gl.getUniformLocation(gl.program, 'u_Sampler1'); if (!u_Sampler1) { console.log('Failed to get the storage location of u_Sampler1'); return false; } loadTexture(gl, texture0, u_Sampler0, imgList[count%imgList.length], 0); loadTexture(gl, texture1, u_Sampler1, imgList[(count+1)%imgList.length], 1); } // 載入材質 function loadTexture(gl, texture, u_Sampler, image, index) { gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1) gl.activeTexture(gl['TEXTURE'+index]) gl.bindTexture(gl.TEXTURE_2D, texture) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); gl.uniform1i(u_Sampler, index); return true; } 複製程式碼
二、轉場效果切換
很多時候我們會使用不同的轉場效果的組合,這裡有兩種思路:
1. 在 shader 中實現轉場的切換
傳入一個記錄轉場次數的變數,在著色器程式碼中判斷第幾次,並切換轉場
precision mediump float; varying vec2 uv; uniform float time;// 變化時間 uniform sampler2D u_Sampler0; uniform sampler2D u_Sampler1; uniform float count;// 迴圈第幾次 void main() { if (count == 1.) { // 第一次轉場 // 播放第一個效果 } else if (count == 2.) { // 第二次轉場 // 播放第二個效果 } } 複製程式碼
這種方式缺點明顯:首先檔案不夠顆粒化,一個檔案存在多個效果;其次邏輯與效果耦合在一起,不便於做不同轉場的任意搭配,比如我有1、2、3種轉場,如果是獨立檔案存放,我可以隨意調整順序 123/132/231/213/312/321/1123/....,控制每個轉場的播放時長。所以更加推薦第二種方式:
2. 每個轉場獨立為檔案,程式碼做切換
// transition1.glsl precision mediump float; varying vec2 uv; uniform float time; uniform sampler2D u_Sampler0; uniform sampler2D u_Sampler1; void main() { // ... } 複製程式碼
// transition2.gls precision mediump float; varying vec2 uv; uniform float time; uniform sampler2D u_Sampler0; uniform sampler2D u_Sampler1; void main() { // ... } 複製程式碼
然後我們在 JavaScript 中控制轉場:
// 在 main() 底部加入這段程式碼 void main() { function render() { var img1 = null; var img2 = null; // 每次移出一張圖來 if (imgList.length > 2) { img1 = imgList.shift() img2 = imgList[0] } else { return; } // 我隨便添加了一個邏輯,在圖片還剩三張的時候,切換第二個轉場。 // 這裡忽略了檔案獲取過程 if (imgList.length == 3) { setShader(gl, VSHADER_SOURCE, FSHADER_SOURCE2); } else { setShader(gl, VSHADER_SOURCE, FSHADER_SOURCE); } // 設定材質 setTexture(gl, img1, img2); // 下面通過 time 和 timeRange 來確定每個輪播的時間(這裡用的是時間戳) // 並通過 getAnimationTime() 來獲取從 0~1 的 progress 時間 var todayTime = (function() { var d = new Date(); d.setHours(0, 0, 0, 0); return d.getTime(); })() var duration = 2000; var startTime = new Date().getTime() - todayTime; var timeRange = gl.getUniformLocation(gl.program, 'timeRange'); gl.uniform2f(timeRange, startTime, duration); var time = gl.getUniformLocation(gl.program, 'time'); gl.uniform1f(time, todayTime); // 因為呼叫 setShader 重新設定了 program,所有所有跟 gl.program 相關的變數要重新賦值 var xxx = gl.getUniformLocation(gl.program, 'xxx'); gl.uniform2f(xxx, 750., 1334.); // 內迴圈,每次把這輪的轉場播放完 var requestId = 0; (function loop(requestId) { var curTime = new Date().getTime() - todayTime; if (curTime <= startTime + duration) { gl.uniform1f(time, curTime) gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.TRIANGLE_FAN, 0, 4); requestId = requestAnimationFrame(loop.bind(this, requestId)) } else { cancelAnimationFrame(requestId) render() } })(requestId) } render() } // 更換材質 function setTexture(gl, img1, img2) { var texture0 = gl.createTexture(); var texture1 = gl.createTexture(); var inputImageTexture = gl.getUniformLocation(gl.program, 'inputImageTexture'); var inputImageTexture2 = gl.getUniformLocation(gl.program, 'inputImageTexture2'); loadTexture(gl, texture0, inputImageTexture, img1, 0); loadTexture(gl, texture1, inputImageTexture2, img2, 1); } // 切換不同的轉場(只需要改變 fshader) function setShader(gl, vshader, fshader) { if (!initShaders(gl, vshader, fshader)) { console.log('Failed to intialize shaders.'); return; } } 複製程式碼

三、材質過渡方式
轉場一般伴隨著兩張圖片的切換,常見的切換方式有兩種:
mix()
1. 線性插值
一般適用於過渡平緩的轉場,能明顯看到兩張圖交替的過程:
return mix(texture2D(u_Sampler0, uv), texture2D(u_Sampler1, uv), progress); 複製程式碼
2. 根據時間切換
一般適用於轉場變化很快的情況下,這種切換肉眼分辨不出來。
if (progress < 0.5) { gl_FragColor = texture2D(u_Sampler0, uv); } else { gl_FragColor = texture2D(u_Sampler1, uv); } 複製程式碼
比如下圖第一個轉場時根據時間瞬間切換紋理(但是看不出來),後者是通過線性插值漸變:

四、動畫速率模擬
基本上所有轉場都不會是簡單到線性勻速運動,所以這裡需要模擬不同的速度曲線。為了還原出更好的轉場效果,需要分幾步:
1. 獲取真實的時間曲線
假設轉場由自己設計,那麼可以使用一些預設好的曲線,如這裡 提供的:

我們可以直接獲取到曲線的貝塞爾公式:

假設轉場效果由他人提供,如設計師使用 AE 製作轉場效果,那麼在 AE 中可以找到相關運動對應的時間變化曲線:

2. 使用速度曲線
拿到曲線之後,接下來當然就是獲取其數學公式,帶入我們的變數中(progress/time/uv.x 等)。
首先需要明確的是,現實世界中的時間是不會變快或變慢的,也就是說時間永遠是勻速運動。只不過當我們在單位時間上施加了公式之後,讓 結果 有了速率上的變化(假如 x 軸的運動是我們的自變數,那麼 y 可以作為因變數)。
#ifdef GL_ES precision mediump float; #endif #define PI 3.14159265359 uniform vec2 u_resolution; uniform vec2 u_mouse; uniform float u_time; float plot(vec2 st, float pct){ returnsmoothstep( pct-0.01, pct, st.y) - smoothstep( pct, pct+0.01, st.y); } float box(vec2 _st, vec2 _size, float _smoothEdges){ _size = vec2(0.5)-_size*0.5; vec2 aa = vec2(_smoothEdges*0.5); vec2 uv = smoothstep(_size,_size+aa,_st); uv *= smoothstep(_size,_size+aa,vec2(1.0)-_st); return uv.x*uv.y; } void main() { vec2 st = gl_FragCoord.xy / u_resolution; vec2 boxst = st + .5; // 這裡用線條繪製出數學公式 y = f(x) // 自變數是 st.x,因變數是 st.y float f_x = sin(st.x*PI); // 這裡則計算小正方形每次運動的位置 // 公式跟上面 f(x) 展示的一樣,只不過 // 我們的因變數從 st.x 變成了 fract(u_time) // fract(u_time) 讓時間永遠從0到1 // 之所以要 *.6 是因為不讓運動太快以至於看不清運動速率變化 boxst.y -= sin(fract(u_time*.6)*PI); boxst.x -= fract(u_time*.6); // 繪製時間曲線和正方形 float box = box(boxst, vec2(.08,.08), 0.001); float pct = plot(st, f_x); vec3 color = pct*vec3(0.0,1.0,0.0)+box; gl_FragColor = vec4(color,1.0); } 複製程式碼

後面我們只需要替換這裡的公式,以 st.x
或 u_time / progress
的時間變數作為自變數,就可以得到相應的運動曲線和動畫呈現了,下面我們可以試試其他動畫曲線:
// 展示部分程式碼 float f_x = pow(st.x, 2.); boxst.y -= pow(fract(u_time*.6), 2.); boxst.x -= fract(u_time*.6); 複製程式碼

float f_x = -(pow((st.x-1.), 2.) -1.); boxst.y -= -(pow((fract(u_time*.6)-1.), 2.) -1.); boxst.x -= fract(u_time*.6); 複製程式碼

// easeInOutQuint float f_x = st.x<.5 ? 16.*pow(st.x, 5.) : 1.+16.*(--st.x)*pow(st.x, 4.); boxst.y -= fract(u_time*.6)<.5 ? 16.*pow(fract(u_time*.6), 5.) : 1.+16.*(fract(u_time*.6)-1.)*pow(fract(u_time*.6)-1., 4.); boxst.x -= fract(u_time*.6); 複製程式碼

// easeInElastic float f_x = ((.04 -.04/st.x) * sin(25.*st.x) + 1.)*.8; boxst.y -= ((.04 -.04/fract(u_time*.6)) * sin(25.*fract(u_time*.6)) + 1.)*.8; boxst.x -= fract(u_time*.6); 複製程式碼

// easeOutElastic float f_x = (.04*st.x /(--st.x)*sin(25.*st.x))+.2; boxst.y -= (.04*fract(u_time*.6)/(fract(u_time*.6)-1.)*sin(25.*fract(u_time*.6)))+.2; boxst.x -= fract(u_time*.6); 複製程式碼

更多的緩動函式:
EasingFunctions = { // no easing, no acceleration linear: function (t) { return t }, // accelerating from zero velocity easeInQuad: function (t) { return t*t }, // decelerating to zero velocity easeOutQuad: function (t) { return t*(2-t) }, // acceleration until halfway, then deceleration easeInOutQuad: function (t) { return t<.5 ? 2*t*t : -1+(4-2*t)*t }, // accelerating from zero velocity easeInCubic: function (t) { return t*t*t }, // decelerating to zero velocity easeOutCubic: function (t) { return (--t)*t*t+1 }, // acceleration until halfway, then deceleration easeInOutCubic: function (t) { return t<.5 ? 4*t*t*t : (t-1)*(2*t-2)*(2*t-2)+1 }, // accelerating from zero velocity easeInQuart: function (t) { return t*t*t*t }, // decelerating to zero velocity easeOutQuart: function (t) { return 1-(--t)*t*t*t }, // acceleration until halfway, then deceleration easeInOutQuart: function (t) { return t<.5 ? 8*t*t*t*t : 1-8*(--t)*t*t*t }, // accelerating from zero velocity easeInQuint: function (t) { return t*t*t*t*t }, // decelerating to zero velocity easeOutQuint: function (t) { return 1+(--t)*t*t*t*t }, // acceleration until halfway, then deceleration easeInOutQuint: function (t) { return t<.5 ? 16*t*t*t*t*t : 1+16*(--t)*t*t*t*t }, // elastic bounce effect at the beginning easeInElastic: function (t) { return (.04 - .04 / t) * sin(25 * t) + 1 }, // elastic bounce effect at the end easeOutElastic: function (t) { return .04 * t / (--t) * sin(25 * t) }, // elastic bounce effect at the beginning and end easeInOutElastic: function (t) { return (t -= .5) < 0 ? (.02 + .01 / t) * sin(50 * t) : (.02 - .01 / t) * sin(50 * t) + 1 }, easeIn: function(t){return function(t){return pow(t, t)}}, easeOut: function(t){return function(t){return 1 - abs(pow(t-1, t))}}, easeInSin: function (t) { return 1 + sin(PI / 2 * t - PI / 2)}, easeOutSin : function (t) {return sin(PI / 2 * t)}, easeInOutSin: function (t) {return (1 + sin(PI * t - PI / 2)) / 2 } } 複製程式碼
3. 如何構造自定義速度曲線
自定義的速度曲線我們可以通過貝塞爾曲線來繪製,如何把我們在 CSS 常用的貝塞爾曲線轉成數學公式?這篇文章給了我們思路,通過對其提供的 JavaScript 程式碼進行改造,得到了一下的 Shader 函式:
float A(float aA1, float aA2) { return 1.0 - 3.0 * aA2 + 3.0 * aA1; } float B(float aA1, float aA2) { return 3.0 * aA2 - 6.0 * aA1; } float C(float aA1) { return 3.0 * aA1; } float GetSlope(float aT, float aA1, float aA2) { return 3.0 * A(aA1, aA2)*aT*aT + 2.0 * B(aA1, aA2) * aT + C(aA1); } float CalcBezier(float aT, float aA1, float aA2) { return ((A(aA1, aA2)*aT + B(aA1, aA2))*aT + C(aA1))*aT; } float GetTForX(float aX, float mX1, float mX2) { float aGuessT = aX; for (int i = 0; i < 4; ++i) { float currentSlope = GetSlope(aGuessT, mX1, mX2); if (currentSlope == 0.0) return aGuessT; float currentX = CalcBezier(aGuessT, mX1, mX2) - aX; aGuessT -= currentX / currentSlope; } return aGuessT; } float KeySpline(float aX, float mX1, float mY1, float mX2, float mY2) { if (mX1 == mY1 && mX2 == mY2) return aX; // linear return CalcBezier(GetTForX(aX, mX1, mX2), mY1, mY2); } 複製程式碼
這段函式應該怎麼使用,首先我們通過貝塞爾曲線編輯器得到四個引數,比如這兩款工具:bezier-easing-editor 或cubic-bezier :

或者

將這四個數字和自變數代入即可得到相應的曲線了,比如我們自己構造了一條曲線:

然後把 .1, .96, .89, .17
代入,就能得到我們想要的運動曲線了:

不過當我們傳入一些特殊值得時候,如 0.99,0.14,0,0.27
會得到一條奇怪的曲線:

實際上想要的曲線是:

這是因為作者在實現轉換到時候並沒有考慮到多角度傾斜等情況,經過他的更新後我們得到了更健壯等程式碼: ofollow,noindex">github.com/gre/bezier-… ,同樣的,我再次把它們轉換成 Shader 函式:
float sampleValues[11]; const float NEWTON_ITERATIONS = 10.; const float NEWTON_MIN_SLOPE = 0.001; const float SUBDIVISION_PRECISION = 0.0000001; const float SUBDIVISION_MAX_ITERATIONS = 10.; float A(float aA1, float aA2) { return 1.0 - 3.0 * aA2 + 3.0 * aA1; } float B(float aA1, float aA2) { return 3.0 * aA2 - 6.0 * aA1; } float C(float aA1) { return 3.0 * aA1; } float getSlope(float aT, float aA1, float aA2) { return 3.0 * A(aA1, aA2)*aT*aT + 2.0 * B(aA1, aA2) * aT + C(aA1); } float calcBezier(float aT, float aA1, float aA2) { return ((A(aA1, aA2)*aT + B(aA1, aA2))*aT + C(aA1))*aT; } float newtonRaphsonIterate(float aX, float aGuessT, float mX1, float mX2) { for (float i = 0.; i < NEWTON_ITERATIONS; ++i) { float currentSlope = getSlope(aGuessT, mX1, mX2); if (currentSlope == 0.0) { return aGuessT; } float currentX = calcBezier(aGuessT, mX1, mX2) - aX; aGuessT -= currentX / currentSlope; } return aGuessT; } float binarySubdivide(float aX, float aA, float aB, float mX1, float mX2) { float currentX, currentT; currentT = aA + (aB - aA) / 2.0; currentX = calcBezier(currentT, mX1, mX2) - aX; if (currentX > 0.0) { aB = currentT; } else { aA = currentT; } for(float i=0.; i<SUBDIVISION_MAX_ITERATIONS; ++i) { if (abs(currentX)>SUBDIVISION_PRECISION) { currentT = aA + (aB - aA) / 2.0; currentX = calcBezier(currentT, mX1, mX2) - aX; if (currentX > 0.0) { aB = currentT; } else { aA = currentT; } } else { break; } } return currentT; } float GetTForX(float aX, float mX1, float mX2, int kSplineTableSize, float kSampleStepSize) { float intervalStart = 0.0; const int lastSample = 10; int currentSample = 1; for (int i = 1; i != lastSample; ++i) { if (sampleValues[i] <= aX) { currentSample = i; intervalStart += kSampleStepSize; } } --currentSample; // Interpolate to provide an initial guess for t float dist = (aX - sampleValues[9]) / (sampleValues[10] - sampleValues[9]); float guessForT = intervalStart + dist * kSampleStepSize; float initialSlope = getSlope(guessForT, mX1, mX2); if (initialSlope >= NEWTON_MIN_SLOPE) { return newtonRaphsonIterate(aX, guessForT, mX1, mX2); } else if (initialSlope == 0.0) { return guessForT; } else { return binarySubdivide(aX, intervalStart, intervalStart + kSampleStepSize, mX1, mX2); } } float KeySpline(float aX, float mX1, float mY1, float mX2, float mY2) { const int kSplineTableSize = 11; float kSampleStepSize = 1. / (float(kSplineTableSize) - 1.); if (!(0. <= mX1 && mX1 <= 1. && 0. <= mX2 && mX2 <= 1.)) { // bezier x values must be in [0, 1] range return 0.; } if (mX1 == mY1 && mX2 == mY2) return aX; // linear for (int i = 0; i < kSplineTableSize; ++i) { sampleValues[i] = calcBezier(float(i)*kSampleStepSize, mX1, mX2); } if (aX == 0.) return 0.; if (aX == 1.) return 1.; return calcBezier(GetTForX(aX, mX1, mX2, kSplineTableSize, kSampleStepSize), mY1, mY2); } 複製程式碼
終於得到了我們想要的運動曲線了:
