Today we will talk about an interesting Flutter animation implementation. If you need to implement a 3D folding animation effect as shown below, what method would you choose?
I believe that many people’s first thought may be: implement it in Dart through matrix transformation and Canvas.
Because this effect is actually considered “common”, in the current novel reader scenario, similar page turning effects are basically achieved through this idea, and I have “fiddled” with this idea a lot before, such as “Cool” 3D Cards and Handsome 360° Display Effects” and using pure code to implement three-dimensional Dash and 3D Nuggets Logo are visual 3D effects achieved using matrix transformation in Dart.
But today, through a project called riveo_page_curl, a different implementation method is provided. That is to implement animation through custom Fragment Shaders. Using custom shaders, you can directly use the GLSL language for programming, and finally achieve through GPU renders richer graphics effects.
Before explaining this project, let’s talk about Fragment Shader first. Flutter started to provide Fragment Shader API in 3.7. As the name suggests, it is a shader that acts on fragments. That is, through Fragment Shader API, developers can Directly intervene in the rendering process of Flutter rendering pipeline.
So what are the benefits of using Fragment Shader directly instead of using Dart matrix transformation? To put it simply, it can reduce the time consuming of the CPU and directly send instructions to the GPU through the graphics language (GLSL). The performance will undoubtedly be improved, and the implementation will be simpler.
However, the act of loading a shader can be expensive, so it must be compiled into the appropriate platform-specific shader at runtime.
Of course, there are conditions for using Fragment Shader in Flutter. For example, it is generally necessary to introduce the #include
header file, because when writing shader code, we all need Know the value of the local coordinates of the current fragment, and flutter/runtime_effect.glsl
provides FlutterFragCoord().xy;
to support accessing local coordinates, which is not a standard GLSL API.
In addition, Fragment Shader only supports .frag
format files and does not support vertex shading files .vert
. It also has the following restrictions:
- UBO and SSBO are not supported
- sampler2D is the only supported sampler type
- texture only supports two parameter versions (sampler and uv)
- Cannot declare additional mutable inputs
- Unsigned integers and boolean values are not supported
Therefore, if you need to move some existing GLSL effects, such as the code on shadertoy, then some necessary “code modifications” cannot be escaped. For example, the following code is a shader for a gradient animation:
void mainImage( out vec4 fragColor, in vec2 fragCoord ){ float strength = 0.4; float t = iTime/3.0; vec3 col = vec3(0); vec2 fC = fragCoord; for(int i = -1; i <= 1; i + + ) { for(int j = -1; j <= 1; j + + ) { fC = fragCoord + vec2(i,j)/3.0; vec2 pos = fC/iResolution.xy; pos.y /= iResolution.x/iResolution.y; pos = 4.0*(vec2(0.5) - pos); for(float k = 1.0; k < 7.0; k + =1.0){ pos.x + = strength * sin(2.0*t + k*1.5 * pos.y) + t*0.5; pos.y + = strength * cos(2.0*t + k*1.5 * pos.x); } col + = 0.5 + 0.5*cos(iTime + pos.xyx + vec3(0,2,4)); } } col /= 9.0; col = pow(col, vec3(0.4545)); fragColor = vec4(col,1.0); }
In Flutter, it needs to be converted into the following code:
- The first is the essential
flutter/runtime_effect.glsl
- Next define the
main()
function - Then we need to move the
out vec4 fragColor;
defined inmainImage
to the global declaration - Because in GLSL iResolution is used to represent the canvas pixel height and width, iTime is the time when the program is running, and here
resolution
andiTime
are defined directly throughuniform
Used to accept input from Dart side, the rest of the logic remains unchanged - Corresponding to
fragCoord
, you can obtain coordinates in Flutter throughFlutterFragCoord
#version 460 core #include <flutter/runtime_effect.glsl> out vec4 fragColor; uniform vec2 resolution; uniform float iTime; void main(){ float strength = 0.25; float t = iTime/8.0; vec3 col = vec3(0); vec2 pos = FlutterFragCoord().xy/resolution.xy; pos = 4.0*(vec2(0.5) - pos); for(float k = 1.0; k < 7.0; k + =1.0){ pos.x + = strength * sin(2.0*t + k*1.5 * pos.y) + t*0.5; pos.y + = strength * cos(2.0*t + k*1.5 * pos.x); } col + = 0.5 + 0.5*cos(iTime + pos.xyx + vec3(0,2,4)); col = pow(col, vec3(0.4545)); fragColor = vec4(col,1.0); }
The first line
#version 460 core
specifies the OpenGL language version used.
It can be seen that converting a piece of GLSL code is not particularly troublesome. It mainly involves changes in coordinates and input parameters. However, these existing fragment shaders can provide us with extremely rich rendering effects, as shown in the following code:
-
Introduce the above shaders code in
pubspec.yaml
-
Load the corresponding
'shaders/warp.frag'
file throughShaderBuilder
to obtainFragmentShader
-
Use
FragmentShader
‘ssetFloat
to pass data -
By adding a shader to draw through
Paint()..shader
, the rendering can be completed
flutter: shaders: - shaders/warp.frag ············· late Ticker _ticker; Duration _elapsed = Duration.zero; @override void initState() {<!-- --> super.initState(); _ticker = createTicker((elapsed) {<!-- --> setState(() {<!-- --> _elapsed = elapsed; }); }); _ticker.start(); } @override Widget build(BuildContext context) => ShaderBuilder( assetKey: 'shaders/warp.frag', (BuildContext context, FragmentShader shader, _) => Scaffold( appBar: AppBar( title: const Text('Warp') ), body: CustomPaint( size: MediaQuery.of(context).size, painter: ShaderCustomPainter(shader, _elapsed) ), ), ); class ShaderCustomPainter extends CustomPainter {<!-- --> final FragmentShader shader; final Duration currentTime; ShaderCustomPainter(this.shader, this.currentTime); @override void paint(Canvas canvas, Size size) {<!-- --> shader.setFloat(0, size.width); shader.setFloat(1, size.height); shader.setFloat(2, currentTime.inMilliseconds.toDouble() / 1000.0); final Paint paint = Paint()..shader = shader; canvas.drawRect(Offset.zero & amp; size, paint); } @override bool shouldRepaint(CustomPainter oldDelegate) => true; }
The only thing that needs explanation here is the shader.setFloat
process, because it actually corresponds to the variables we have in the .frag
file through indexing. To put it simply:
Here we define
uniform vec2 resolution;
anduniform float iTime;
in GLSL, then vec2 resolution occupies index 0 and 1, and float iTime occupies index 2.
The general understanding is that vec2 means two float type values are stored together, so the vec2 resolution declared first occupies indexes 0 and 1. For example, as shown in the figure below, vec2 and vec3 are divided at this time. It occupies the index 0-4.
By defining the value in the GLSL shader through uniform
, the corresponding data can be passed through the index of setFloat
in Dart, thus forming a complete closed loop of data interaction.
The complete code of the gradient animation here in Flutter can be found in warp.frag in Github https://github.com/tbuczkowski/flutter_shaders.
At the same time, for the entire gradient animation mentioned above, the author also provides a comparison of the corresponding pure Dart code to achieve the same effect in the warehouse. From the data, it can be seen that the implementation using shaders has greatly improved the performance.
So looking back, the folding shader in the riveo_page_curl project looks like this, except for a bunch of unintelligible matrix changes, such as scale
scaling, translate
translation and project
In addition to projection conversion, there are various incomprehensible trigonometric function calculations. The simple core is to calculate the radian of the curved part when the matrix changes, and to add shadow projection to improve the visual effect.
#include <flutter/runtime_effect.glsl> uniform vec2 resolution; uniform float pointer; uniform float origin; uniform vec4 container; uniform float cornerRadius; uniform sampler2D image; const float r = 150.0; const float scaleFactor = 0.2; #definePI 3.14159265359 #define TRANSPARENT vec4(0.0, 0.0, 0.0, 0.0) mat3 translate(vec2 p) { return mat3(1.0, 0.0, 0.0, 0.0, 1.0, 0.0, p.x, p.y, 1.0); } mat3 scale(vec2 s, vec2 p) { return translate(p) * mat3(s.x, 0.0, 0.0, 0.0, s.y, 0.0, 0.0, 0.0, 1.0) * translate(-p); } vec2 project(vec2 p, mat3 m) { return (inverse(m) * vec3(p, 1.0)).xy; } struct Paint { vec4 color; bool stroke; float strokeWidth; int blendMode; }; struct Context { vec4 color; vec2p; vec2 resolution; }; bool inRect(vec2 p, vec4 rct) { bool inRct = p.x > rct.x & amp; & amp; p.x < rct.z & amp; & amp; p.y > rct.y & amp; & amp; p.y < rct.w; if (!inRct) { return false; } // Top left corner if (p.x < rct.x + cornerRadius & amp; & amp; p.y < rct.y + cornerRadius) { return length(p - vec2(rct.x + cornerRadius, rct.y + cornerRadius)) < cornerRadius; } // Top right corner if (p.x > rct.z - cornerRadius & amp; & amp; p.y < rct.y + cornerRadius) { return length(p - vec2(rct.z - cornerRadius, rct.y + cornerRadius)) < cornerRadius; } // Bottom left corner if (p.x < rct.x + cornerRadius & amp; & amp; p.y > rct.w - cornerRadius) { return length(p - vec2(rct.x + cornerRadius, rct.w - cornerRadius)) < cornerRadius; } // Bottom right corner if (p.x > rct.z - cornerRadius & amp; & amp; p.y > rct.w - cornerRadius) { return length(p - vec2(rct.z - cornerRadius, rct.w - cornerRadius)) < cornerRadius; } return true; } out vec4 fragColor; void main() { vec2 xy = FlutterFragCoord().xy; vec2 center = resolution * 0.5; float dx = origin - pointer; float x = container.z - dx; float d = xy.x - x; if (d > r) { fragColor = TRANSPARENT; if (inRect(xy, container)) { fragColor.a = mix(0.5, 0.0, (d-r)/r); } } else if (d > 0.0) { float theta = asin(d / r); float d1 = theta * r; float d2 = (3.14159265 - theta) * r; vec2 s = vec2(1.0 + (1.0 - sin(3.14159265/2.0 + theta)) * 0.1); mat3 transform = scale(s, center); vec2 uv = project(xy, transform); vec2 p1 = vec2(x + d1, uv.y); s = vec2(1.1 + sin(3.14159265/2.0 + theta) * 0.1); transform = scale(s, center); uv = project(xy, transform); vec2 p2 = vec2(x + d2, uv.y); if (inRect(p2, container)) { fragColor = texture(image, p2 / resolution); } else if (inRect(p1, container)) { fragColor = texture(image, p1 / resolution); fragColor.rgb *= pow(clamp((r - d) / r, 0.0, 1.0), 0.2); } else if (inRect(xy, container)) { fragColor = vec4(0.0, 0.0, 0.0, 0.5); } } else { vec2 s = vec2(1.2); mat3 transform = scale(s, center); vec2 uv = project(xy, transform); vec2 p = vec2(x + abs(d) + 3.14159265 * r, uv.y); if (inRect(p, container)) { fragColor = texture(image, p / resolution); } else { fragColor = texture(image, xy / resolution); } } }
In fact, I know that everyone does not care about its implementation logic, but more about how to use it. The key information here is uniform sampler2D image
. By introducing sampler2D
, we can Dart passes ui.Image
to GLSL through setImageSampler(0, image);
, so that the above-mentioned folding animation logic can be implemented for the Flutter control.
Corresponding to the Dart layer, in addition to ShaderBuilder
, you can also use flutter_shaders’ AnimatedSampler
to implement more concise shader
and image
and canvas
, the biggest role of AnimatedSampler
is to take a screenshot of the entire child through PictureRecorder
and convert it into ui .Image
is passed to GLSL to complete the UI delivery interactive effect.
Widget _buildAnimatedCard(BuildContext context, Widget? child) {<!-- --> return ShaderBuilder( (context, shader, _) {<!-- --> return AnimatedSampler( (image, size, canvas) {<!-- --> _configureShader(shader, size, image); _drawShaderRect(shader, size, canvas); }, child: Padding( padding: EdgeInsets.symmetric(vertical: cornerRadius), child: widget.child, ), ); }, assetKey: 'shaders/page_curl.frag', ); void _configureShader(FragmentShader shader, Size size, ui.Image image) {<!-- --> shader ..setFloat(0, size.width) // resolution ..setFloat(1, size.height) // resolution ..setFloat(2, _animationController.value) // pointer ..setFloat(3, 0) // origin ..setFloat(4, 0) // inner container ..setFloat(5, 0) // inner container ..setFloat(6, size.width) // inner container ..setFloat(7, size.height) // inner container ..setFloat(8, cornerRadius) // cornerRadius ..setImageSampler(0, image); // image } void _drawShaderRect(FragmentShader shader, Size size, Canvas canvas) {<!-- --> canvas.drawRect( Rect.fromCenter( center: Offset(size.width / 2, size.height / 2), width: size.width, height: size.height, ), Paint()..shader = shader, ); }
The complete project can be found at: https://github.com/Rahiche/riveo_page_curl
So it can be seen thatcompared to implementing such 3D page folding in the Dart layer, the code implemented using FragmentShader
will be simpler, and the performance experience will be better than the pure Dart implementation< /strong>The most important thing is that some shader codes similar to ShaderToy can be directly applied to Flutter through simple transplantation and adaptation, which is undoubtedly very friendly to the implementation of Flutter in game scenes.
Finally, after Flutter 3.10, Flutter Web also supports fragment shaders, so the implementation of shaders in Flutter is relatively mature. So if it is the logic conversion of “Implementation of the “Glitch” Effect of Neon Text” that I implemented through Flutter If it is done as fragment shaders, will the performance and code simplicity be higher?