前言
转场效果在视频编辑工具中最为常见,在两段视频或图像之间增加一个「过渡」的效果,可以让整个过程更佳柔滑自然。常见的转场如渐变过渡、旋转、擦除等(下图为 iMovie 自带转场):
而且现在很多视频 App 中也自带了影集功能,你可以选择不同的转场来制作出动态影集:
而在 WebGL 实现转场,相比起编辑器有很大的不同,这些不同带来了一些思考:
一、材质切换时机
在之前这篇文章中提到了两张材质的切换,但一般影集都会大于两张图片,如何让整个切换能够循环且无感知?这里通过一个简单到动画来示例:
简单解释下,假设我们的转场效果是从右往左切换(正如动图所示),切换的时机就是每轮动画的结束,对u_Sampler0
和u_Sampler1
进行重新赋值,每轮动画的第一张图就是上一轮动画的下一张图,这种瞬间的赋值会让整个动画无变化感知,从而实现不同材质的循环,并且不会占用 WebGL 中太多的纹理空间(只需要两个),这种方式也是来自于 Web 端 Slider 的编写经验。
相关代码如下:
1// 更换材质
2function changeTexture(gl, imgList, count) {
3 var texture0 = gl.createTexture();
4 var texture1 = gl.createTexture();
5
6 if (!texture0 && !texture1) {
7 console.log('Failed to create the texture object');
8 return false;
9 }
10
11 var u_Sampler0 = gl.getUniformLocation(gl.program, 'u_Sampler0');
12 if (!u_Sampler0) {
13 console.log('Failed to get the storage location of u_Sampler0');
14 return false;
15 }
16 var u_Sampler1 = gl.getUniformLocation(gl.program, 'u_Sampler1');
17 if (!u_Sampler1) {
18 console.log('Failed to get the storage location of u_Sampler1');
19 return false;
20 }
21
22 loadTexture(gl, texture0, u_Sampler0, imgList[count%imgList.length], 0);
23 loadTexture(gl, texture1, u_Sampler1, imgList[(count+1)%imgList.length], 1);
24}
25
26// 加载材质
27function loadTexture(gl, texture, u_Sampler, image, index) {
28 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)
29 gl.activeTexture(gl['TEXTURE'+index])
30 gl.bindTexture(gl.TEXTURE_2D, texture)
31 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
32 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
33 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
34 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
35 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
36 gl.uniform1i(u_Sampler, index);
37 return true;
38}
二、转场效果切换
很多时候我们会使用不同的转场效果的组合,这里有两种思路:
1. 在 shader 中实现转场的切换
传入一个记录转场次数的变量,在着色器代码中判断第几次,并切换转场
1precision mediump float;
2varying vec2 uv;
3uniform float time; // 变化时间
4uniform sampler2D u_Sampler0;
5uniform sampler2D u_Sampler1;
6
7uniform float count; // 循环第几次
8
9void main() {
10
11 if (count == 1.) {
12 // 第一次转场
13 // 播放第一个效果
14 }
15 else if (count == 2.) {
16 // 第二次转场
17 // 播放第二个效果
18 }
19}
这种方式缺点明显:首先文件不够颗粒化,一个文件存在多个效果;其次逻辑与效果耦合在一起,不便于做不同转场的任意搭配,比如我有1、2、3种转场,如果是独立文件存放,我可以随意调整顺序 123/132/231/213/312/321/1123/….,控制每个转场的播放时长。所以更加推荐第二种方式:
2. 每个转场独立为文件,代码做切换
1// transition1.glsl
2precision mediump float;
3varying vec2 uv;
4uniform float time;
5uniform sampler2D u_Sampler0;
6uniform sampler2D u_Sampler1;
7
8void main() {
9 // ...
10}
1// transition2.gls
2precision mediump float;
3varying vec2 uv;
4uniform float time;
5uniform sampler2D u_Sampler0;
6uniform sampler2D u_Sampler1;
7
8void main() {
9 // ...
10}
然后我们在 JavaScript 中控制转场:
1// 在 main() 底部加入这段代码
2void main() {
3 function render() {
4 var img1 = null;
5 var img2 = null;
6
7 // 每次移出一张图来
8 if (imgList.length > 2) {
9 img1 = imgList.shift()
10 img2 = imgList[0]
11 } else {
12 return;
13 }
14
15 // 我随便添加了一个逻辑,在图片还剩三张的时候,切换第二个转场。
16 // 这里忽略了文件获取过程
17 if (imgList.length == 3) {
18 setShader(gl, VSHADER_SOURCE, FSHADER_SOURCE2);
19 } else {
20 setShader(gl, VSHADER_SOURCE, FSHADER_SOURCE);
21 }
22
23 // 设置材质
24 setTexture(gl, img1, img2);
25
26 // 下面通过 time 和 timeRange 来确定每个轮播的时间(这里用的是时间戳)
27 // 并通过 getAnimationTime() 来获取从 0~1 的 progress 时间
28 var todayTime = (function() {
29 var d = new Date();
30 d.setHours(0, 0, 0, 0);
31 return d.getTime();
32 })()
33
34 var duration = 2000;
35 var startTime = new Date().getTime() - todayTime;
36
37 var timeRange = gl.getUniformLocation(gl.program, 'timeRange');
38 gl.uniform2f(timeRange, startTime, duration);
39 var time = gl.getUniformLocation(gl.program, 'time');
40 gl.uniform1f(time, todayTime);
41
42 // 因为调用 setShader 重新设置了 program,所有所有跟 gl.program 相关的变量要重新赋值
43 var xxx = gl.getUniformLocation(gl.program, 'xxx');
44 gl.uniform2f(xxx, 750., 1334.);
45
46 // 内循环,每次把这轮的转场播放完
47 var requestId = 0;
48 (function loop(requestId) {
49 var curTime = new Date().getTime() - todayTime;
50 if (curTime <= startTime + duration) {
51 gl.uniform1f(time, curTime)
52 gl.clear(gl.COLOR_BUFFER_BIT);
53 gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);
54 requestId = requestAnimationFrame(loop.bind(this, requestId))
55 } else {
56 cancelAnimationFrame(requestId)
57 render()
58 }
59 })(requestId)
60 }
61 render()
62}
63
64
65// 更换材质
66function setTexture(gl, img1, img2) {
67 var texture0 = gl.createTexture();
68 var texture1 = gl.createTexture();
69
70 var inputImageTexture = gl.getUniformLocation(gl.program, 'inputImageTexture');
71 var inputImageTexture2 = gl.getUniformLocation(gl.program, 'inputImageTexture2');
72
73 loadTexture(gl, texture0, inputImageTexture, img1, 0);
74 loadTexture(gl, texture1, inputImageTexture2, img2, 1);
75}
76
77// 切换不同的转场(只需要改变 fshader)
78function setShader(gl, vshader, fshader) {
79 if (!initShaders(gl, vshader, fshader)) {
80 console.log('Failed to intialize shaders.');
81 return;
82 }
83}
三、材质过渡方式
转场一般伴随着两张图片的切换,常见的切换方式有两种:
-
两张图像素点的线性插值
mix()
,切换比较柔和 -
根据时间来切换,切换比较生硬
1. 线性插值
一般适用于过渡平缓的转场,能明显看到两张图交替的过程:
1return mix(texture2D(u_Sampler0, uv), texture2D(u_Sampler1, uv), progress);
2. 根据时间切换
一般适用于转场变化很快的情况下,这种切换肉眼分辨不出来。
1if (progress < 0.5) {
2 gl_FragColor = texture2D(u_Sampler0, uv);
3} else {
4 gl_FragColor = texture2D(u_Sampler1, uv);
5}
比如下图第一个转场时根据时间瞬间切换纹理(但是看不出来),后者是通过线性插值渐变:
四、动画速率模拟
基本上所有转场都不会是简单到线性匀速运动,所以这里需要模拟不同的速度曲线。为了还原出更好的转场效果,需要分几步:
1. 获取真实的时间曲线
假设转场由自己设计,那么可以使用一些预设好的曲线,如 这里 提供的:
我们可以直接获取到曲线的贝塞尔公式:
假设转场效果由他人提供,如设计师使用 AE 制作转场效果,那么在 AE 中可以找到相关运动对应的时间变化曲线:
2. 使用速度曲线
拿到曲线之后,接下来当然就是获取其数学公式,带入我们的变量中(progress/time/uv.x 等)。
首先需要明确的是,现实世界中的时间是不会变快或变慢的,也就是说时间永远是匀速运动。只不过当我们在单位时间上施加了公式之后,让结果有了速率上的变化(假如 x 轴的运动是我们的自变量,那么 y 可以作为因变量)。
1#ifdef GL_ES
2precision mediump float;
3#endif
4
5#define PI 3.14159265359
6uniform vec2 u_resolution;
7uniform vec2 u_mouse;
8uniform float u_time;
9
10float plot(vec2 st, float pct){
11 return smoothstep( pct-0.01, pct, st.y) -
12 smoothstep( pct, pct+0.01, st.y);
13}
14
15float box(vec2 _st, vec2 _size, float _smoothEdges){
16 _size = vec2(0.5)-_size*0.5;
17 vec2 aa = vec2(_smoothEdges*0.5);
18 vec2 uv = smoothstep(_size,_size+aa,_st);
19 uv *= smoothstep(_size,_size+aa,vec2(1.0)-_st);
20 return uv.x*uv.y;
21}
22
23void main() {
24 vec2 st = gl_FragCoord.xy / u_resolution;
25 vec2 boxst = st + .5;
26
27 // 这里用线条绘制出数学公式 y = f(x)
28 // 自变量是 st.x,因变量是 st.y
29 float f_x = sin(st.x*PI);
30
31 // 这里则计算小正方形每次运动的位置
32 // 公式跟上面 f(x) 展示的一样,只不过
33 // 我们的因变量从 st.x 变成了 fract(u_time)
34 // fract(u_time) 让时间永远从0到1
35 // 之所以要 *.6 是因为不让运动太快以至于看不清运动速率变化
36 boxst.y -= sin(fract(u_time*.6)*PI);
37 boxst.x -= fract(u_time*.6);
38
39 // 绘制时间曲线和正方形
40 float box = box(boxst, vec2(.08,.08), 0.001);
41 float pct = plot(st, f_x);
42
43 vec3 color = pct*vec3(0.0,1.0,0.0)+box;
44 gl_FragColor = vec4(color,1.0);
45}
后面我们只需要替换这里的公式,以st.x
或u_time / progress
的时间变量作为自变量,就可以得到相应的运动曲线和动画呈现了,下面我们可以试试其他动画曲线:
1// 展示部分代码
2float f_x = pow(st.x, 2.);
3
4boxst.y -= pow(fract(u_time*.6), 2.);
5boxst.x -= fract(u_time*.6);
1float f_x = -(pow((st.x-1.), 2.) -1.);
2
3boxst.y -= -(pow((fract(u_time*.6)-1.), 2.) -1.);
4boxst.x -= fract(u_time*.6);
1// easeInOutQuint
2float f_x = st.x<.5 ? 16.*pow(st.x, 5.) : 1.+16.*(--st.x)*pow(st.x, 4.);
3
4boxst.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.);
5boxst.x -= fract(u_time*.6);
1// easeInElastic
2float f_x = ((.04 -.04/st.x) * sin(25.*st.x) + 1.)*.8;
3
4boxst.y -= ((.04 -.04/fract(u_time*.6)) * sin(25.*fract(u_time*.6)) + 1.)*.8;
5boxst.x -= fract(u_time*.6);
1// easeOutElastic
2float f_x = (.04*st.x /(--st.x)*sin(25.*st.x))+.2;
3
4boxst.y -= (.04*fract(u_time*.6)/(fract(u_time*.6)-1.)*sin(25.*fract(u_time*.6)))+.2;
5boxst.x -= fract(u_time*.6);
更多的缓动函数:
1EasingFunctions = {
2 // no easing, no acceleration
3 linear: function (t) { return t },
4 // accelerating from zero velocity
5 easeInQuad: function (t) { return t*t },
6 // decelerating to zero velocity
7 easeOutQuad: function (t) { return t*(2-t) },
8 // acceleration until halfway, then deceleration
9 easeInOutQuad: function (t) { return t<.5 ? 2*t*t : -1+(4-2*t)*t },
10 // accelerating from zero velocity
11 easeInCubic: function (t) { return t*t*t },
12 // decelerating to zero velocity
13 easeOutCubic: function (t) { return (--t)*t*t+1 },
14 // acceleration until halfway, then deceleration
15 easeInOutCubic: function (t) { return t<.5 ? 4*t*t*t : (t-1)*(2*t-2)*(2*t-2)+1 },
16 // accelerating from zero velocity
17 easeInQuart: function (t) { return t*t*t*t },
18 // decelerating to zero velocity
19 easeOutQuart: function (t) { return 1-(--t)*t*t*t },
20 // acceleration until halfway, then deceleration
21 easeInOutQuart: function (t) { return t<.5 ? 8*t*t*t*t : 1-8*(--t)*t*t*t },
22 // accelerating from zero velocity
23 easeInQuint: function (t) { return t*t*t*t*t },
24 // decelerating to zero velocity
25 easeOutQuint: function (t) { return 1+(--t)*t*t*t*t },
26 // acceleration until halfway, then deceleration
27 easeInOutQuint: function (t) { return t<.5 ? 16*t*t*t*t*t : 1+16*(--t)*t*t*t*t },
28 // elastic bounce effect at the beginning
29 easeInElastic: function (t) { return (.04 - .04 / t) * sin(25 * t) + 1 },
30 // elastic bounce effect at the end
31 easeOutElastic: function (t) { return .04 * t / (--t) * sin(25 * t) },
32 // elastic bounce effect at the beginning and end
33 easeInOutElastic: function (t) { return (t -= .5) < 0 ? (.02 + .01 / t) * sin(50 * t) : (.02 - .01 / t) * sin(50 * t) + 1 },
34 easeIn: function(t){return function(t){return pow(t, t)}},
35 easeOut: function(t){return function(t){return 1 - abs(pow(t-1, t))}},
36 easeInSin: function (t) { return 1 + sin(PI / 2 * t - PI / 2)},
37 easeOutSin : function (t) {return sin(PI / 2 * t)},
38 easeInOutSin: function (t) {return (1 + sin(PI * t - PI / 2)) / 2 }
39}
3. 构造自定义速度曲线
自定义的速度曲线我们可以通过贝塞尔曲线来绘制,如何把我们在 CSS 常用的贝塞尔曲线转成数学公式?这篇文章给了我们思路,通过对其提供的 JavaScript 代码进行改造,得到了以下的 Shader 函数:
1float A(float aA1, float aA2) {
2 return 1.0 - 3.0 * aA2 + 3.0 * aA1;
3}
4
5float B(float aA1, float aA2) {
6 return 3.0 * aA2 - 6.0 * aA1;
7}
8
9float C(float aA1) {
10 return 3.0 * aA1;
11}
12
13float GetSlope(float aT, float aA1, float aA2) {
14 return 3.0 * A(aA1, aA2)*aT*aT + 2.0 * B(aA1, aA2) * aT + C(aA1);
15}
16
17float CalcBezier(float aT, float aA1, float aA2) {
18 return ((A(aA1, aA2)*aT + B(aA1, aA2))*aT + C(aA1))*aT;
19}
20
21float GetTForX(float aX, float mX1, float mX2) {
22 float aGuessT = aX;
23 for (int i = 0; i < 4; ++i) {
24 float currentSlope = GetSlope(aGuessT, mX1, mX2);
25 if (currentSlope == 0.0) return aGuessT;
26 float currentX = CalcBezier(aGuessT, mX1, mX2) - aX;
27 aGuessT -= currentX / currentSlope;
28 }
29 return aGuessT;
30}
31
32float KeySpline(float aX, float mX1, float mY1, float mX2, float mY2) {
33 if (mX1 == mY1 && mX2 == mY2) return aX; // linear
34 return CalcBezier(GetTForX(aX, mX1, mX2), mY1, mY2);
35}
这段函数应该怎么使用,首先我们通过贝塞尔曲线编辑器得到四个参数,比如这两款工具:bezier-easing-editor 或 cubic-bezier :
或者
将这四个数字和自变量代入即可得到相应的曲线了,比如我们自己构造了一条曲线:
然后把.1, .96, .89, .17
代入,就能得到我们想要的运动曲线了:
不过当我们传入一些特殊值得时候,如 0.99,0.14,0,0.27
会得到一条奇怪的曲线:
实际上想要的曲线是:
这是因为作者在实现转换到时候并没有考虑到多角度倾斜等情况,经过他的更新后我们得到了更健壮等代码:github.com/gre/bezier-… ,同样的,我再次把它们转换成 Shader 函数:
1float sampleValues[11];
2const float NEWTON_ITERATIONS = 10.;
3const float NEWTON_MIN_SLOPE = 0.001;
4const float SUBDIVISION_PRECISION = 0.0000001;
5const float SUBDIVISION_MAX_ITERATIONS = 10.;
6
7float A(float aA1, float aA2) {
8 return 1.0 - 3.0 * aA2 + 3.0 * aA1;
9}
10
11float B(float aA1, float aA2) {
12 return 3.0 * aA2 - 6.0 * aA1;
13}
14
15float C(float aA1) {
16 return 3.0 * aA1;
17}
18
19float getSlope(float aT, float aA1, float aA2) {
20 return 3.0 * A(aA1, aA2)*aT*aT + 2.0 * B(aA1, aA2) * aT + C(aA1);
21}
22
23float calcBezier(float aT, float aA1, float aA2) {
24 return ((A(aA1, aA2)*aT + B(aA1, aA2))*aT + C(aA1))*aT;
25}
26
27float newtonRaphsonIterate(float aX, float aGuessT, float mX1, float mX2) {
28 for (float i = 0.; i < NEWTON_ITERATIONS; ++i) {
29 float currentSlope = getSlope(aGuessT, mX1, mX2);
30 if (currentSlope == 0.0) {
31 return aGuessT;
32 }
33 float currentX = calcBezier(aGuessT, mX1, mX2) - aX;
34 aGuessT -= currentX / currentSlope;
35 }
36 return aGuessT;
37}
38
39float binarySubdivide(float aX, float aA, float aB, float mX1, float mX2) {
40 float currentX, currentT;
41
42 currentT = aA + (aB - aA) / 2.0;
43 currentX = calcBezier(currentT, mX1, mX2) - aX;
44 if (currentX > 0.0) {
45 aB = currentT;
46 } else {
47 aA = currentT;
48 }
49
50 for(float i=0.; i<SUBDIVISION_MAX_ITERATIONS; ++i) {
51 if (abs(currentX)>SUBDIVISION_PRECISION) {
52 currentT = aA + (aB - aA) / 2.0;
53 currentX = calcBezier(currentT, mX1, mX2) - aX;
54 if (currentX > 0.0) {
55 aB = currentT;
56 } else {
57 aA = currentT;
58 }
59 } else {
60 break;
61 }
62 }
63
64 return currentT;
65}
66
67float GetTForX(float aX, float mX1, float mX2, int kSplineTableSize, float kSampleStepSize) {
68 float intervalStart = 0.0;
69 const int lastSample = 10;
70 int currentSample = 1;
71
72 for (int i = 1; i != lastSample; ++i) {
73 if (sampleValues[i] <= aX) {
74 currentSample = i;
75 intervalStart += kSampleStepSize;
76 }
77 }
78 --currentSample;
79
80 // Interpolate to provide an initial guess for t
81 float dist = (aX - sampleValues[9]) / (sampleValues[10] - sampleValues[9]);
82
83 float guessForT = intervalStart + dist * kSampleStepSize;
84
85 float initialSlope = getSlope(guessForT, mX1, mX2);
86
87 if (initialSlope >= NEWTON_MIN_SLOPE) {
88 return newtonRaphsonIterate(aX, guessForT, mX1, mX2);
89 } else if (initialSlope == 0.0) {
90 return guessForT;
91 } else {
92 return binarySubdivide(aX, intervalStart, intervalStart + kSampleStepSize, mX1, mX2);
93 }
94}
95
96float KeySpline(float aX, float mX1, float mY1, float mX2, float mY2) {
97 const int kSplineTableSize = 11;
98 float kSampleStepSize = 1. / (float(kSplineTableSize) - 1.);
99
100 if (!(0. <= mX1 && mX1 <= 1. && 0. <= mX2 && mX2 <= 1.)) {
101 // bezier x values must be in [0, 1] range
102 return 0.;
103 }
104 if (mX1 == mY1 && mX2 == mY2) return aX; // linear
105
106 for (int i = 0; i < kSplineTableSize; ++i) {
107 sampleValues[i] = calcBezier(float(i)*kSampleStepSize, mX1, mX2);
108 }
109
110 if (aX == 0.) return 0.;
111 if (aX == 1.) return 1.;
112
113 return calcBezier(GetTForX(aX, mX1, mX2, kSplineTableSize, kSampleStepSize), mY1, mY2);
114}
终于得到了我们想要的运动曲线了:
有了贝塞尔曲线这个强大的工具,基本可以满足我们对任意动画变化速率的需求了。为我们实现优雅自然的转场效果提供了强大的保障。
下面通过两张动图感受下匀速运动和非匀速运动对转场带来的细微感官差异(第一张图是匀速的,第二张图加了贝塞尔曲线,GIF 会影响最终效果,但能大致感受):
相关链接:
-
https://gist.github.com/gre/1650294
-
greweb.me/2012/02/bez…
原文链接: https://juejin.cn/post/6844903686037045255
— END —
进技术交流群,扫码添加我的微信:Byte-Flow
版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。