【Flutter&GLSL】用Fragment Shader来实现高性能的动画效果——翻页动画(一)
至于使用fragment shader的原因,其实一个字:性能!
- 仿真翻页动画帧率波动太大,边界翻页的时候性能很差。
- 3d球性能一般。
那么是时候 make flutter_novel great again了
思路整理
其中的3d翻页效果,固定方向为横向,实现上相较简单,先从这块开始上手
这个横向翻页效果,还真用附带的那一张图就能说明:
要看懂这张图,首先需要对片段着色器的实现方式有一定的了解:
片段着色器处理展示效果的过程,可以说是根据输入的像素点位置,找到其在传入的图片中对应位置像素点的颜色,最后返回这个颜色值的过程。
而图片中的几个参数结合相关代码,可以推导出步骤是这样的:
-
先模拟一个圆柱出来,圆柱的半径为r,圆柱圆心的横坐标为x,圆柱用来模拟页面卷曲效果。
-
定义一个浮点数d,代表圆心到当前正在处理的像素点的距离。
-
如果d大于半径,那么说明当前像素点的位置处于翻页动画的卷曲范围外,对应这部分的色值就应该是透明色
-
如果d是正数,且不大于半径r,那么处于这部分:
这部分图中代码并未体现的一点的是,如果像图中的情况一样,像素点所处位置可能对应圆柱上两个点:p2,p1,我该如何通过代码决策用哪个点?
这点的解决方案其实也很简单,先不论是否存在p2点,假设一定会存在p2点的情况,计算出p1和p2两点的位置:
p1点的计算方式就是简单的三角函数,先定义p1,圆心跟纵轴的夹角为θ,根据三角函数可知sinθ = d/r,那么θ的值就是d/r的反正弦。得到了θ的弧度值之后,对应的弧长通过半径*弧度即可得到,得到的弧长就是对应图片纹理上的x坐标。
根据同样的道理可以试图计算出p2的x坐标,由于p2和p1是镜像关系,因此计算弧长的时候仅仅将传入的θ改为π-θ即可。
最终如果p2点横坐标处于纹理的范围内(比如说归一化后大于0,小于1)那么说明存在p2,直接用p2即可,否则就用p1。
-
最后,如果d是负数,那说明在剩余部分,这部分处理的逻辑也跟d是正数的情况相同,需要判断下是不是有过度滑动超出的p1和p2两点:
由于如果不存在p2点,直接展示原有的图片纹理即可,因此只需要判断下有没有p2点即可,p2点的横向距离等于整个卷曲部分弧长(π*R)+ 圆柱圆心的横坐标(x)+ 当前判断像素点的横坐标跟圆柱圆心横坐标的绝对值(abs(xy.x-x)); 同样的,如果这个p2点最终算出来的横坐标在整个纹理范围内,那说明存在这个点,直接使用p2点的纹理即可,否则用xy.x的纹理即可。
应用改造
在菠萝大佬翻译的文章中,原大佬作者已经将写好的着色器上传到shadertoy(其实第一次知道这个东西还是靠issue的提醒……)中,那么直接在这个基础上进行修改就好了,这个是原理说明图:
那么列举下如果要应用到flutter_novel 中所需要的修改内容:
- 让卷曲后的页脚固定在触摸点,因此需要调整curl axis的生成规则。
- 卷曲页部分的阴影
- 小修改来支持上一页翻回来的效果
curl axis的修改方案
如果想要要翻起后的页角跟随手势触摸点,需要修改的内容一个是角度,另一个是卷曲轴的位置
角度的问题,可以将原理图中的click坐标改为最近的边角(右上角和右下角),这部分完全可以根据触摸起点的位置来决定;
curl axis的的问题就棘手一些,毕竟控制的是卷曲轴而非页脚,所以这里需要通过计算来修改同步,最终整体是这样的:
#include <flutter/runtime_effect.glsl>
uniform vec2 resolution;
uniform vec4 iMouse;
uniform sampler2D image;
#define pi 3.14159265359
#define radius 0.05
#define TRANSPARENT vec4(0.0, 0.0, 0.0, 0.0)
out vec4 fragColor;
void main() {
vec2 fragCoord = FlutterFragCoord().xy;
float aspect = resolution.x / resolution.y;
vec2 uv = fragCoord * vec2(aspect, 1.0) / resolution.xy;
// 归一化鼠标坐标
vec2 mouse = iMouse.xy * vec2(aspect, 1.0) / resolution.xy;
vec2 cornerFrom = (iMouse.w<resolution.y/2)?vec2(resolution.x, 0.0):vec2(resolution.x, resolution.y);
// 鼠标方向的向量
vec2 mouseDir = normalize(abs(cornerFrom) - iMouse.xy);
// 翻页原点的计算,可以视为转换为横轴下的x轴起点位置
vec2 origin = clamp(mouse - mouseDir * mouse.x / mouseDir.x, 0.0, 1.0);
// 鼠标拖动距离
float mouseDist = distance(mouse, origin);
// float mouseDist = clamp(length(mouse - origin)
// + (aspect - (abs(cornerFrom.x) / resolution.x) * aspect) / mouseDir.x, 0.0, aspect / mouseDir.x);
// 如果鼠标方向向左,那么鼠标拖动距离就是鼠标到原点的距离
if (mouseDir.x < 0.0)
{
mouseDist = distance(mouse, origin);
}
float proj = dot(uv - origin, mouseDir);
float dist = proj - mouseDist;
vec2 curlAxisLinePoint = uv - dist * mouseDir;
if (!(distance(mouse, cornerFrom* vec2(aspect, 1.0) / resolution.xy)<pi*radius)){
float params = (distance(mouse, cornerFrom* vec2(aspect, 1.0) / resolution.xy)-pi*radius)/2;
curlAxisLinePoint = uv - dist * mouseDir +params*mouseDir;
dist -=params;
}
if (dist > radius)
{
fragColor = TRANSPARENT;
fragColor.rgb *= pow(clamp(dist - radius, 0.0, 1.0) * 1.5, 0.2);
}
else if (dist >= 0.0)
{
// map to cylinder point
float theta = asin(dist / radius);
vec2 p2 = curlAxisLinePoint + mouseDir * (pi - theta) * radius;
vec2 p1 = curlAxisLinePoint + mouseDir * theta * radius;
if (p2.x <= aspect && p2.y <= 1.0 && p2.x > 0.0 && p2.y > 0.0){
uv = p2;
fragColor = texture(image, uv * vec2(1.0 / aspect, 1.0));
fragColor.rgb *= pow(clamp((radius - dist) / radius, 0.0, 1.0), 0.2);
} else {
uv = p1;
fragColor = texture(image, uv * vec2(1.0 / aspect, 1.0));
fragColor.rgb *= pow(clamp((radius - dist) / radius, 0.0, 1.0), 0.2);
}
}
else
{
vec2 p = curlAxisLinePoint + mouseDir * (abs(dist) + pi * radius);
if (p.x <= aspect && p.y <= 1.0 && p.x > 0.0 && p.y > 0.0){
uv = p;
fragColor = texture(image, uv * vec2(1.0 / aspect, 1.0));
// } else if ((p.x <= aspect+0.02 && p.y <= 1.0+0.02) && (p.x > 0.0 - 0.02 && p.y > 0.0- 0.02)){
//
// vec3 col = texture(image, uv * vec2(1.0 / aspect, 1.0)).rgb;
// fragColor = vec4(col*smoothstep(-1.0, 1.0, 0.5), 1.0);
} else {
fragColor = texture(image, uv * vec2(1.0 / aspect, 1.0));
if (distance(uv, origin) < 0.02){
fragColor = TRANSPARENT;
}
}
}
}
如何使用
flutter 中使用fragment shader的方式有很多种,不过大体上最终都是通过Paint..shader = <FragmentShader>
来实现的
在这个例子中,结合的是ShaderBuilder
+AnimatedSampler
来实现的,举个例子:
Stack(
children: [
Positioned.fill(
child: Container(
height: double.infinity,
width: double.infinity,
color: Colors.blue,
padding: const EdgeInsets.symmetric(
vertical: ProjectCardConstants.cornerRadius,
),
child: Text(
'很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字\n\n很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字\n\n很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字\n\n很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字\n\n',
style: TextStyle(fontSize: 15),
),
)),
Positioned.fill(
child: GestureDetector(
onPanDown: (details) {
setState(() {
downPosition = details.localPosition;
updatePosition = details.localPosition;
developer.log(details.toString(), name: 'down');
});
},
onPanUpdate: (details) {
setState(() {
updatePosition = details.localPosition;
developer.log(details.localPosition.toString(), name: 'update');
});
},
onPanEnd: (details) {
setState(() {
downPosition = Offset.zero;
updatePosition = Offset.zero;
developer.log(details.velocity.toString(), name: 'end');
});
},
onPanCancel: () {
setState(() {
downPosition = Offset.zero;
updatePosition = Offset.zero;
developer.log('', name: 'cancel');
});
},
child: ShaderBuilder(
(context, shader, _) {
return AnimatedSampler(
(image, size, canvas) {
shader
..setFloat(0, size.width) // resolution
..setFloat(1, size.height) // resolution
..setFloat(2, updatePosition.dx) // mouse
..setFloat(3, updatePosition.dy) // mouse
..setFloat(4, downPosition.dx) // mouse
..setFloat(5, downPosition.dy) // mouse
..setImageSampler(0, image); // image
ShaderHelper.drawShaderRect(shader, size, canvas);
},
child: Container(
height: double.infinity,
width: double.infinity,
color: Colors.red,
padding: const EdgeInsets.symmetric(
vertical: ProjectCardConstants.cornerRadius,
),
child: Text(
'很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字\n\n很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字\n\n很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字\n\n很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字很多文字\n\n',
style: TextStyle(fontSize: 15),
),
),
);
},
assetKey: ProjectCardConstants.shaderAsset2Key,
),
)),
],
)
drawShaderRect
方法就是直接在canvas上进行绘制而已:
static void drawShaderRect(
ui.FragmentShader shader, ui.Size size, ui.Canvas canvas) {
canvas.drawRect(
ui.Rect.fromCenter(
center: ui.Offset(size.width / 2, size.height / 2),
width: size.width,
height: size.height,
),
ui.Paint()..shader = shader,
);
}
这段代码中,AnimatedSampler负责采样目标控件的纹理,ShaderBuilder将canvas区域暴露出来,这样就可以绘制shader处理后的内容了;
需要修改绘制内容的话,也仅仅需要将AnimatedSampler的child变更一下就可以了。
小结
目前翻页动画的雏形就已经实现了,不过距离完美复刻,还需要处理下阴影效果,同时还需要增加翻页的范围限制。
最主要的是应用到整个小说阅读器上,让其能够体现出来整体的翻页效果,这部分就放到后面再说吧,现在轮到我去当帕鲁了
转载自:https://juejin.cn/post/7337207433867886619