Implementing Particle Snap Effect in Flutter Using Shaders and OverlayPortal
The article shows how to create a Thanos‑style particle snap animation in Flutter by capturing a widget screenshot, inserting an OverlayPortal layer, and applying a custom fragment shader that treats each pixel as a moving, fading particle, with detailed GLSL code and shader loading steps.
This article explains how to create a Thanos‑style particle snap animation in Flutter by combining a widget screenshot, an OverlayPortal layer, and a custom fragment shader.
The technique works as follows: first, the target widget is rendered to an image; then, an overlay layer is inserted via OverlayPortal ; finally, a fragment shader processes the screenshot to animate each pixel as a particle that moves outward and fades out.
Using a shader is preferred over pure Dart because Flutter’s default image APIs are relatively weak, and GPU‑based fragment shading provides far better performance for per‑pixel effects.
Loading and applying a shader in Flutter involves three steps: final ui.FragmentProgram program = await ui.FragmentProgram.fromAsset(widget.shaderAsset); , final shader = program.fragmentShader(); , setting uniform values with methods like setFloat and setImageSampler , and drawing with canvas.drawRect while binding the shader to a Paint object.
The GLSL source defines constants for the particle movement angle range ( -2.2 to -0.76 ), helper functions to compute delays based on particle lifetime and column index, a pseudo‑random angle function, and calculateInitialParticleIndex which maps a pixel to its originating particle block, handling edge cases where the particle has not yet moved.
The main function normalizes coordinates, iterates over the allowed angles, computes each particle’s center position, adjusted time, and the zero‑point pixel position that samples the original texture. If the sampled point lies within the particle’s bounds, the shader outputs the texture color modulated by a fade‑out factor; otherwise it outputs transparent black.
Key snippets from the shader are shown below (code lines are kept unchanged and wrapped in tags):
#define min_movement_angle -2.2 #define max_movement_angle -0.76 #define movement_angles_count 10 #define movement_angle_step (max_movement_angle - min_movement_angle) / movement_angles_count #define pi 3.14159265359 uniform float animationValue; uniform float particleLifetime; uniform float fadeOutDuration; uniform float particlesInRow; uniform float particlesInColumn; uniform float particleSpeed; uniform vec2 uSize; uniform sampler2D uImageTexture; out vec4 fragColor; float delayFromParticleCenterPos(float x) { return (1.0 - particleLifetime) * x; } float delayFromColumnIndex(int i) { return (1.0 - particleLifetime) * (i / particlesInRow); } float randomAngle(int i) { float randomValue = fract(sin(float(i) * 12.9898 + 78.233) * 43758.5453); return min_movement_angle + floor(randomValue * movement_angles_count) * movement_angle_step; } int calculateInitialParticleIndex(vec2 point, float angle, float animationValue, float particleWidth, float particleHeight) { float x0 = (point.x - animationValue * cos(angle) * particleSpeed) / (1.0 - (1.0 - particleLifetime) * cos(angle) * particleSpeed); float delay = delayFromParticleCenterPos(x0); float y0 = point.y - (animationValue - delay) * sin(angle) * particleSpeed; if (angle <= -pi / 2.0 && point.x >= x0) return int(point.x / particleWidth) + int(point.y / particleHeight) * int(1.0 / particleWidth); if (angle >= -pi / 2.0 && point.x < x0) return int(point.x / particleWidth) + int(point.y / particleHeight) * int(1.0 / particleWidth); return int(x0 / particleWidth) + int(y0 / particleHeight) * int(1.0 / particleWidth); } void main() { vec2 uv = FlutterFragCoord().xy / uSize.xy; float particleWidth = 1.0 / particlesInRow; float particleHeight = 1.0 / particlesInColumn; float particlesCount = (1.0 / particleWidth) * (1.0 / particleHeight); for (float searchMovementAngle = min_movement_angle; searchMovementAngle <= max_movement_angle; searchMovementAngle += movement_angle_step) { int i = calculateInitialParticleIndex(uv, searchMovementAngle, animationValue, particleWidth, particleHeight); if (i < 0 || i >= particlesCount) continue; float angle = randomAngle(i); vec2 particleCenterPos = vec2(mod(float(i), 1.0 / particleWidth) * particleWidth + particleWidth / 2.0, int(float(i) / (1.0 / particleWidth)) * particleHeight + particleHeight / 2.0); float delay = delayFromParticleCenterPos(particleCenterPos.x); float adjustedTime = max(0.0, animationValue - delay); vec2 zeroPointPixelPos = vec2(uv.x - adjustedTime * cos(angle) * particleSpeed, uv.y - adjustedTime * sin(angle) * particleSpeed); if (zeroPointPixelPos.x >= particleCenterPos.x - particleWidth / 2.0 && zeroPointPixelPos.x <= particleCenterPos.x + particleWidth / 2.0 && zeroPointPixelPos.y >= particleCenterPos.y - particleHeight / 2.0 && zeroPointPixelPos.y <= particleCenterPos.y + particleHeight / 2.0) { vec4 zeroPointPixelColor = texture(uImageTexture, zeroPointPixelPos); float alpha = zeroPointPixelColor.a; float fadeOutLifetime = max(0.0, adjustedTime - (particleLifetime - fadeOutDuration));<|reserved_token_163842|>fragColor = zeroPointPixelColor * (1.0 - fadeOutLifetime / fadeOutDuration); return; } } fragColor = vec4(0.0, 0.0, 0.0, 0.0); }
The article also shows how to port a water shader from Shadertoy to Flutter, illustrating the adaptation of uniforms and helper functions for use with flutter_shaders .
In summary, the thanos_snap_effect demonstrates that Flutter shaders, combined with screenshot capture and OverlayPortal , can produce impressive high‑performance visual effects, opening the door for more advanced GPU‑driven animations in Flutter applications.
Sohu Tech Products
A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.