Skip to main content

Fragment shaders

Running GLSL on the GPU for every pixel a widget covers. This is how you get procedural materials, animated backgrounds, post-processing, and — with raymarching — genuinely three-dimensional images without any geometry.

Contents

What Flutter supports

  • Fragment shaders only. There is no vertex shader stage exposed. You paint a region, and your code runs per pixel in that region.
  • GLSL source, compiled at build time. The shader is compiled when the app is built, not at runtime, so a syntax error is a build failure.
  • Uniforms are floats and image samplers. No integer, boolean, matrix, or struct uniforms — pack everything into floats.
  • Impeller is the backend on mobile. Check platform support for the exact Flutter version you target if you also ship web or desktop.

Declaring a shader

Put the source in the project and list it under flutter: in pubspec.yaml:

flutter:
shaders:
- shaders/ripple.frag
- shaders/raymarch.frag

The extension .frag is conventional. Files listed here are compiled and bundled as assets.

Shader structure

#version 460 core

#include <flutter/runtime_effect.glsl>

uniform vec2 uSize; // the size of the area being painted
uniform float uTime;

out vec4 fragColor;

void main() {
// FlutterFragCoord() gives the local coordinate of this fragment
vec2 uv = FlutterFragCoord().xy / uSize;

vec3 colour = vec3(uv.x, uv.y, abs(sin(uTime)));
fragColor = vec4(colour, 1.0);
}

Three requirements: the #version 460 core header, the flutter/runtime_effect.glsl include, and an out vec4 for the result. Use FlutterFragCoord() rather than gl_FragCoord, because Flutter's coordinate origin does not match GL's convention.

Colours are premultiplied. If you output partial alpha, multiply the RGB by alpha or the result will look wrong when composited.

Loading and painting

class ShaderBackground extends StatefulWidget {
const ShaderBackground({super.key});

@override
State<ShaderBackground> createState() => _ShaderBackgroundState();
}

class _ShaderBackgroundState extends State<ShaderBackground>
with SingleTickerProviderStateMixin {
late final Ticker _ticker;
ui.FragmentShader? _shader;
double _time = 0;

@override
void initState() {
super.initState();
_ticker = createTicker((elapsed) {
setState(() => _time = elapsed.inMicroseconds / 1e6);
})..start();
_load();
}

Future<void> _load() async {
final program = await ui.FragmentProgram.fromAsset('shaders/ripple.frag');
if (!mounted) return;
setState(() => _shader = program.fragmentShader());
}

@override
void dispose() {
_ticker.dispose();
_shader?.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
final shader = _shader;
if (shader == null) return const SizedBox.expand();

return RepaintBoundary(
child: CustomPaint(
size: Size.infinite,
painter: _ShaderPainter(shader, _time),
),
);
}
}

class _ShaderPainter extends CustomPainter {
_ShaderPainter(this.shader, this.time);

final ui.FragmentShader shader;
final double time;

@override
void paint(Canvas canvas, Size size) {
shader
..setFloat(0, size.width) // uSize.x
..setFloat(1, size.height) // uSize.y
..setFloat(2, time); // uTime

canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
}

@override
bool shouldRepaint(_ShaderPainter oldDelegate) =>
oldDelegate.time != time || oldDelegate.shader != shader;
}

Import dart:ui as ui for FragmentProgram and FragmentShader.

Uniforms

Uniform values are set by flat float index, in declaration order, with each component counting separately:

uniform vec2 uSize; // indices 0, 1
uniform float uTime; // index 2
uniform vec3 uColour; // indices 3, 4, 5
shader
..setFloat(0, size.width)
..setFloat(1, size.height)
..setFloat(2, time)
..setFloat(3, colour.r)
..setFloat(4, colour.g)
..setFloat(5, colour.b);

There is no name lookup, so reordering the declarations silently changes the meaning of every index. Keep a comment mapping indices to uniforms, or generate the bindings with a helper such as flutter_shaders.

Sampling images

uniform sampler2D uTexture;

void main() {
vec2 uv = FlutterFragCoord().xy / uSize;
fragColor = texture(uTexture, uv);
}
shader.setImageSampler(0, image); // ui.Image, indexed separately from floats

Samplers have their own index space starting at 0, independent of the float indices.

Animating

Driving the shader from setState (as above) is simple but rebuilds the widget every frame. The cheaper pattern is to pass the animation into the painter's repaint argument, so only paint runs:

class _ShaderPainter extends CustomPainter {
_ShaderPainter(this.shader, this.animation) : super(repaint: animation);

final ui.FragmentShader shader;
final Animation<double> animation;

@override
void paint(Canvas canvas, Size size) {
shader
..setFloat(0, size.width)
..setFloat(1, size.height)
..setFloat(2, animation.value);
canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
}

@override
bool shouldRepaint(_ShaderPainter oldDelegate) => false;
}

See Custom painting for why this matters.

Raymarching a 3D scene

A fragment shader can render a 3D scene without any mesh: for each pixel, cast a ray from a virtual camera and step along it until it hits a surface defined by a signed distance function. The result has real perspective, surface normals, and lighting, but no geometry to upload and no draw calls.

#version 460 core

#include <flutter/runtime_effect.glsl>

uniform vec2 uSize;
uniform float uTime;

out vec4 fragColor;

mat2 rot(float a) {
float c = cos(a);
float s = sin(a);
return mat2(c, s, -s, c);
}

// Signed distance to a sphere and a rounded box
float sdSphere(vec3 p, float r) {
return length(p) - r;
}

float sdRoundBox(vec3 p, vec3 b, float r) {
vec3 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0) - r;
}

// The scene: distance to the nearest surface
float map(vec3 p) {
vec3 q = p;
q.xz = rot(uTime * 0.6) * q.xz;
q.xy = rot(uTime * 0.4) * q.xy;

float box = sdRoundBox(q, vec3(0.7), 0.08);
float ball = sdSphere(p - vec3(0.0, sin(uTime) * 0.4, 0.0), 0.95);
return min(box, ball);
}

// Surface normal from the gradient of the distance field
vec3 normalAt(vec3 p) {
vec2 e = vec2(0.0015, 0.0);
return normalize(vec3(
map(p + e.xyy) - map(p - e.xyy),
map(p + e.yxy) - map(p - e.yxy),
map(p + e.yyx) - map(p - e.yyx)));
}

void main() {
// Centred, aspect-corrected coordinates
vec2 uv = (FlutterFragCoord().xy * 2.0 - uSize) / uSize.y;
uv.y = -uv.y;

vec3 ro = vec3(0.0, 0.0, -3.2); // ray origin (camera)
vec3 rd = normalize(vec3(uv, 1.6)); // ray direction

float t = 0.0;
bool hit = false;

for (int i = 0; i < 80; i++) {
vec3 p = ro + rd * t;
float d = map(p);
if (d < 0.001) {
hit = true;
break;
}
t += d;
if (t > 20.0) {
break;
}
}

vec3 colour = vec3(0.03, 0.04, 0.06); // background

if (hit) {
vec3 p = ro + rd * t;
vec3 n = normalAt(p);
vec3 lightDir = normalize(vec3(0.6, 0.8, -0.5));

float diffuse = max(dot(n, lightDir), 0.0);
float rim = pow(1.0 - max(dot(n, -rd), 0.0), 3.0);

vec3 base = vec3(0.12, 0.31, 0.85);
colour = base * (0.15 + diffuse) + vec3(rim * 0.4);
}

fragColor = vec4(colour, 1.0);
}

What each part does:

  • map defines the scene. Combine shapes with min for union, max for intersection, and max(a, -b) for subtraction. Rotating the sample point rotates the object.
  • The loop is sphere tracing: the distance function tells you how far you can safely advance without passing through a surface.
  • normalAt estimates the surface normal by sampling the field around the hit point — that is what makes lighting possible without vertex data.
  • The shading is a plain Lambert term plus a rim highlight. Add shadows by marching a second ray toward the light, and ambient occlusion by sampling the field along the normal.

This is the cheapest way to get real 3D into a Flutter app when the subject is procedural rather than modelled. For actual meshes exported from a 3D tool, see 3D rendering overview.

Sampling the widget below

To post-process existing widgets — blur, distortion, transitions — you need the subtree rendered into an image first. The flutter_shaders package provides AnimatedSampler, which hands your builder a ui.Image of the child:

flutter pub add flutter_shaders
AnimatedSampler(
(image, size, canvas) {
shader
..setFloat(0, size.width)
..setFloat(1, size.height)
..setFloat(2, progress)
..setImageSampler(0, image);
canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
},
child: const DashboardContent(),
)

This costs an offscreen render of the child every frame, so apply it to a bounded subtree and only while the effect is running.

Performance

  • Cost scales with painted area times loop iterations. A full-screen raymarch at 3x device pixel ratio is millions of iterations per frame.
  • Cap the march loop and break early; keep the iteration count as low as the image tolerates.
  • Render into a smaller area and let Flutter scale it up when the effect is soft — halving the resolution quarters the work.
  • Keep the shader in a RepaintBoundary so it does not drag neighbours into its repaint.
  • Prefer mediump precision where the platform allows it; high precision is slower on mobile GPUs.
  • Test on a real mid-range device. Shader cost is where desktop-class hardware will mislead you most.

Limitations

  • No vertex stage, so geometry must be procedural or drawn some other way.
  • No uniform arrays or structs; pack data into floats or a texture.
  • No compute shaders.
  • Dynamic loop bounds are best avoided; use a constant bound with an early break.
  • Shaders are compiled per platform backend — verify on every platform you ship, not only on your development machine.

Next: 3D rendering overview.