shader_graph 0.0.4
shader_graph: ^0.0.4 copied to clipboard
A multi-pass render-graph framework for Flutter runtime shaders (FragmentProgram/RuntimeEffect), with Shadertoy-style buffers and feedback.
Shader Graph #
shader_graph is a real-time multi-pass shader execution framework for Flutter
FragmentProgram/RuntimeEffect.
It can even run a full game implemented entirely in shaders.
It runs multiple .frag passes as a “render graph” (including Shadertoy-style
BufferA/BufferB/Main, feedback, ping-pong).
It supports keyboard input, mouse input, image inputs, and Shadertoy-style Wrap (Clamp/Repeat/Mirror).
If you just want to quickly display a shader, you can use a simple Widget (for
example ShaderSurface.auto).
When you need a more complex pipeline (multi-pass / multiple inputs / feedback /
ping-pong), use ShaderBuffer to declare inputs and dependencies.
The framework handles the topological scheduling and per-frame execution, and
forwards each pass output as a ui.Image to downstream passes.
English | 中文 README
Screenshots #
Games #
Bricks Game |
Pacman Game |
Demos #
IFrame |
Mac Wallpaper |
Noise Lab |
Text |
Wrap |
Float #
Float Test |
I have already used this library to create awesome_flutter_shaders, which contains 100+ ported shader examples.
Features #
- ✅ Support using a Shader as a Buffer, then feeding it into another Shader (Multi-Pass)
- ✅ Support feeding images as Buffer inputs into shaders
- ✅ Support feedback inputs (Ping-Pong: feed previous frame into next frame)
- ✅ Mouse input
- ✅ Keyboard input
- ✅ Wrap (Clamp/Repeat/Mirror)
- ✅ Automatic topological sorting
- ✅ texelFetch support with automatic texel size computation (requires macros and shader code changes)
Float support (RGBA8 feedback)
Flutter feedback textures are typically RGBA8, which cannot reliably store
arbitrary float state.
This project provides a unified porting scheme sg_feedback_rgba8: encode
scalars into RGB (24-bit), and preserve Shadertoy-like “one texel = vec4”
semantics via 4-lane horizontal packing.
texelFetch support
Replace native texelFetch calls with the SG_TEXELFETCH / SG_TEXELFETCH0..3
macros from common_header.frag (using iChannelResolution0..3 as channel
pixel sizes).
Roadmap #
- ❌ Render a Widget into a texture, then feed it as an input Buffer to a shader
- ❌ Support Shadertoy-style Filter (Linear/Nearest/Mipmap). This directly affects whether some ports match.
Preface #
My understanding of shaders used to be vague. A friend recommended The Book of Shaders. I read part of it, but I still didn't fully understand the underlying ideas. However, I found Shadertoy shaders incredibly fun — some of them are even full games. That's crazy, so I wanted to port them to Flutter.
First, thanks to the author of shader_buffers, which helped me start porting shaders to Flutter.
While using that library, I found gaps between what I needed and what the original design provided, so I contributed fixes via PRs.
But I still had too many requirements left to implement — not only for shader_buffers, but for almost every shader framework in Flutter — so shader_graph was born.
Usage #
First, you must understand that Shadertoy shader code needs to be ported before it can run on Flutter. This repo includes a porting prompt: port_shader.prompt
Usage: open the shader asset path you want to port (it should live in the project), then in Copilot or similar AI tools, input the following prompt:
Follow instructions in [port_shader.prompt.md](.github/prompts/port_shader.prompt.md).
This repo includes fairly complete example code. See example
Minimal runnable examples #
1) Single shader (Widget)
SizedBox(
height: 240,
// shader_asset_main ends with .frag
child: ShaderSurface.auto('$shader_asset_main'),
)
2) Two passes (A → Main) See multi_pass.dart
ShaderSurface.builder(() {
final bufferA = '$shader_asset_buffera'.shaderBuffer;
final main = '$shader_asset_main'.shaderBuffer.feed(bufferA);
return [bufferA, main];
})
3) feedback (A → A, plus a Main display) See bricks_game.dart
ShaderSurface.builder(() {
final bufferA = '$asset_shader_buffera'.feedback().feedKeyboard();
final mainBuffer = '$asset_shader_main'.feed(bufferA);
// Standard scheme: physical width = virtual * 4
bufferA.fixedOutputSize = const Size(14 * 4.0, 14);
return [bufferA, mainBuffer];
})
ShaderBuffer #
It can be the final render shader, or an intermediate Buffer that feeds into another shader. Typically we use the extension to create it:
'$asset_path'.shaderBuffer;
Which is equivalent to:
final buffer = ShaderBuffer('$asset_path');
Use it with ShaderSurface.auto / ShaderSurface.builder, or use
ShaderSurface.buffers to pass a List<ShaderBuffer>.
Add inputs #
Inputs are added uniformly via the extension buffer.feed method. It infers
the input type from the string suffix. You can also use the raw APIs like
feedShader / feedShaderFromAsset / ...
Feed another shader as an input
// use ShaderBuffer directly
buffer.feed(anotherBuffer);
// use string path which ends with .frag
buffer.feed("$asset_path");
Keyboard input
buffer.feedKeyboard();
Feed an asset image
Commonly used for noise/texture inputs
You can see examples in awesome_flutter_shaders
buffer.feed('$image_asset_path');
Ping-Pong
That is, feeding itself into itself. Don't worry about infinite loops; shader_graph handles it.
This keeps the original Shadertoy semantics, and it's a very common pattern on Shadertoy.
buffer.feedback();
Set Wrap (repeat/mirror/clamp)
Flutter runtime shaders don't expose sampler wrap/filter states directly.
This project models Wrap via a shader-side UV transform through the uniform
iChannelWrap (x/y/z/w correspond to iChannel0..3).
Set wrap per input on the Dart side:
final buffer = '$shader_asset_path'.shaderBuffer;
buffer.feed('$texture_asset_path', wrap: WrapMode.repeat);
On the shader side, sample using SG_TEX0/1/2/3(...) provided by
common_header.frag (do not call texture(iChannelN, uv) directly).
ShaderSurface.auto #
ShaderSurface.auto returns a Widget:
Center(
child: ShaderSurface.auto('$shader_asset_path'),
)
You can place it anywhere. Note that you usually need to provide a height:
Column(
children: [
Text('This is a shader:'),
Expanded(
child: ShaderSurface.auto('$shader_asset_path'),
),
],
)
ShaderSurface.auto supports String (shader asset path) / ShaderBuffer /
List<ShaderBuffer>.
When the shader has inputs, passing a ShaderBuffer is more appropriate.
Builder(builder: (context) {
final mainBuffer = '$shader_asset_path'.shaderBuffer;
mainBuffer.feed('$noise_asset_path');
return ShaderSurface.auto(mainBuffer);
}),
Or using the extension:
ShaderSurface.auto(
'$shader_asset_path'.shaderBuffer.feed('$noise_asset_path'),
);
Using Extensions #
When multiple ShaderBuffers need inputs, it becomes like this:
Column(
children: [
Text('This is a shader:'),
Builder(builder: (context) {
final mainBuffer = ShaderBuffer('$shader_asset_path');
mainBuffer.feedImageFromAsset('$noise_asset_path');
return ShaderSurface.auto(mainBuffer);
}),
Builder(builder: (context) {
final mainBuffer = ShaderBuffer('$shader_asset_path');
mainBuffer.feedImageFromAsset('$noise_asset_path');
return ShaderSurface.auto(mainBuffer);
}),
],
)
With Extensions it can be optimized to:
Column(
children: [
Text('This is a shader:'),
ShaderSurface.auto(
'$shader_asset_path'.feed('$noise_asset_path'),
),
ShaderSurface.auto(
'$shader_asset_path'.feed('$noise_asset_path'),
),
],
)
ShaderSurface.builder #
The examples above only have a single shader. But for complex pipelines, for example:
┌─────┐ ┌─────┐ ┌─────┐
│ A │───▶│ B │───▶│ C │
│ ↺ A │ └─────┘ └─────┘
└─────┘
Or:
┌──────────── Shader A ────────────┐
│ │
│ ┌─────┐ │
│ │ A │◀───────────────┐ │
│ └──┬──┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌─────┐ │ │
│ │ B │────────────────┘ │
│ └──┬──┘ │
│ ▼ │
│ ┌─────┐ │
│ │ C │ │
│ └──┬──┘ │
└──────┼───────────────────────────┘
▼
┌─────────┐
│ D │
│ A B C │
└─────────┘
ShaderSurface provides builder to handle these cases. Used like this, you
won't need multiple Builder(Flutter).
Builder doesn't disappear; it just moves.
ShaderSurface.builder(() {
final bufferA = '$asset_shader_buffera'.feedback().feedKeyboard();
final mainBuffer = '$asset_shader_main'.feed(bufferA);
// Standard scheme: physical width = virtual * 4
bufferA.fixedOutputSize = const Size(14 * 4.0, 14);
return [bufferA, mainBuffer];
})
Topological sorting #
For Shadertoy multi-pass pipelines, the final Buffer list can be topologically sorted only when the per-frame dependency graph has no cycles (i.e. it is a DAG).
In other words: within the same frame, passes may read outputs from passes they depend on (or external inputs), but must not form a cycle (for example A reads B while B reads A).
Feedback / ping-pong reads the previous frame output, which is a cross-frame dependency and typically does not break the current-frame topological order.
Note: for inputs within a single Buffer, you must still feed them in Shadertoy iChannel order (iChannel0..N), because channels are bound sequentially.
See pacman_game.dart
class PacmanGame extends StatefulWidget {
const PacmanGame({super.key});
@override
State<PacmanGame> createState() => _PacmanGameState();
}
class _PacmanGameState extends State<PacmanGame> {
late final List<int> _order;
@override
void initState() {
super.initState();
_order = [0, 1, 2]..shuffle(Random(DateTime.now().microsecondsSinceEpoch));
}
@override
Widget build(BuildContext context) {
return ShaderSurface.builder(
() {
final bufferA = 'shaders/game_ported/Pacman Game BufferA.frag'.shaderBuffer;
final bufferB = 'shaders/game_ported/Pacman Game BufferB.frag'.shaderBuffer;
final mainBuffer = 'shaders/game_ported/Pacman Game.frag'.shaderBuffer;
bufferA.fixedOutputSize = const Size(32 * 4.0, 32);
bufferA.feedback().feedKeyboard();
bufferB.feedShader(bufferA);
mainBuffer.feedShader(bufferA).feedShader(bufferB);
final buffers = [bufferA, bufferB, mainBuffer];
return _order.map((i) => buffers[i]).toList(growable: false);
},
);
}
}
ShaderToy → Flutter porting guide (Feedback/Wrap) #
Key background: Flutter RuntimeEffect/SkSL does not expose real sampler states (wrap/filter can't be set like Shadertoy). Some GLSL features are also limited (for example
texelFetch, bit operations, global array initialization, etc.). This project ports common Shadertoy code into a runnable form via “header files + macros + Dart-side uniforms/samplers wiring”.
0. Key files and terms #
- Unified header (must include):
example/shaders/common/common_header.frag
- Shadertoy main entry wrapper:
example/shaders/common/main_shadertoy.frag
- RGBA8 feedback encoding utilities (optional include, depends on common_header):
example/shaders/common/sg_feedback_rgba8.frag
- Dart-side inputs and wrap:
lib/src/shader_input.dartlib/src/shader_buffer.dart
Terms:
- pass/buffer: Shadertoy BufferA/BufferB/Main intermediate render targets
- feedback: reading previous frame output (state machines / game logic / score / positions)
- virtual texel: logical state grid (for example 14×14)
- physical pixel: the actual output pixels. To simulate “one texel = vec4”,
sg_feedback_rgba8expands one virtual texel into 4 horizontal physical pixels.
1. Correct wrap usage (repeat/mirror/clamp) #
1.1 Dart side: set wrap per input channel
This project models wrap via WrapMode (encoded as floats into iChannelWrap):
WrapMode.clampWrapMode.repeatWrapMode.mirror
Example (illustration):
final buf = 'shaders/xxx.frag'.shaderBuffer
..feed('assets/tex.png', wrap: WrapMode.repeat)
..feed('assets/tex2.png', wrap: WrapMode.mirror);
Mapping:
iChannelWrap.x→ iChannel0iChannelWrap.y→ iChannel1iChannelWrap.z→ iChannel2iChannelWrap.w→ iChannel3
Note: this is not a real GPU sampler state. Wrap is implemented via a shader-side UV transform.
1.2 Shader side: sampling must go through wrap macros
common_header.frag provides:
sg_wrapUv(uv, mode): clamp/repeat/mirror UV transformSG_TEX0/1/2/3(tex, uv): samples using the correspondingiChannelWrapcomponent
Therefore in your shader:
- Do not call
texture(iChannelN, uv)directly (it ignores wrap configuration) - Do call:
vec4 c0 = SG_TEX0(iChannel0, uv);
vec4 c1 = SG_TEX1(iChannel1, uv);
If you prefer an explicit form:
vec2 u = sg_wrapUv(uv, iChannelWrap.x);
vec4 c0 = texture(iChannel0, u);
1.3 About UV semantics
- Many Shadertoy shaders sample textures in
[0,1]UV space. - Some shaders use centered coordinates (for example
uv = (fragCoord - 0.5*iResolution)/iResolution.y, roughly[-1,1]).
Wrap is mathematically defined as clamp/repeat/mirror over the input UV:
- If your UV is not in
[0,1], repeat/mirror still works, but the visual result may differ from “standard texture coordinates” (this is expected).
2. sg_feedback_rgba8: RGBA8 feedback (previous frame) spec #
2.1 Why it exists
Flutter intermediate render targets are typically ui.Image (RGBA8). Writing
high-precision float state directly into RGBA8 often causes:
- insufficient precision / quantization jitter
- slight neighbor mixing on some GPU paths
- once
NaN/Infis written, it keeps contaminating future frames
Goals of sg_feedback_rgba8:
- stable state storage in RGBA8
- reduce linear-sampling crosstalk for state machines
2.2 Include order
Include in this order:
#include <../common/common_header.frag>
#include <../common/sg_feedback_rgba8.frag>
Note: sg_feedback_rgba8.frag depends on macros like SG_TEXELFETCH provided by common_header.frag.
2.3 Virtual texels and physical output size
sg_feedback_rgba8 expands lanes horizontally to simulate storing a vec4 per texel:
- virtual
(x, y)maps to physical(x*4 + lane, y), lane=0..3 maps to vec4 x/y/z/w
So:
- virtual size =
VSIZE = vec2(VW, VH) - physical output size =
(VW*4, VH)
Dart side must match:
- set
fixedOutputSize = Size(VW*4, VH)for the data buffer
Otherwise reads/writes will be offset.
2.4 Read/write API (macros + store functions)
Read: SG_LOAD_* macros (explicit channel token)
Example:
const vec2 VSIZE = vec2(14.0, 14.0);
vec4 s = SG_LOAD_VEC4(iChannel0, ivec2(0, 0), VSIZE);
float a = SG_LOAD_FLOAT(iChannel0, ivec2(1, 0), VSIZE);
vec3 v = SG_LOAD_VEC3(iChannel0, ivec2(2, 0), VSIZE);
Key point:
- Always use
SG_LOAD_*and pass the channel token explicitly (iChannelN).
Write: sg_storeVec4 / sg_storeVec4Range
At the end of mainImage(out vec4 fragColor, in vec2 fragCoord), write by register address:
ivec2 p = ivec2(fragCoord - 0.5);
fragColor = vec4(0.0);
sg_storeVec4(txSomeReg, valueSigned, fragColor, p);
Where:
pis the physical pixel coord (typicallyivec2(fragCoord - 0.5))valueSignedmust be encoded into [-1,1] (see next section)
2.5 Range encoding: map any range to [-1,1]
sg_feedback_rgba8 storage assumes:
- scalar channels are stored in [-1, 1]
So map real ranges (for example score 0..50000) into [-1,1], and decode after reading.
Common helpers (in sg_feedback_rgba8.frag):
sg_encodeRangeToSigned(v, min, max)sg_decodeSignedToRange(s, min, max)sg_encode01ToSigned(v01)/sg_decodeSignedTo01(s)
2.6 Crosstalk mitigation (important)
On some GPU paths, sampler2D sampling can be slightly linear, mixing lanes
(x*4+0..3) and corrupting state.
Recommendations:
- for “single scalar” registers: write
vec4(v,v,v,v) - for reads of those registers: average (for example
dot(raw, vec4(0.25)))
2.7 NaN/Inf protection (feedback can contaminate forever)
Once NaN/Inf is written, it spreads on future frames.
Common triggers:
- division by 0
normalize(v)/inversesqrt(dot(v,v))whenvis near 0log(0)
Mitigations:
- clamp denominators (for example
max(abs(x), 1e-6)) - check vector length before normalizing
3. texelFetch replacement (Plan A: per-channel resolution uniforms) #
common_header.frag provides:
uniform vec2 iChannelResolution0..3;SG_TEXELFETCH(tex, ipos, sizePx): texel-center UV + snap replacementSG_TEXELFETCH0/1/2/3(ipos): convenience macros foriChannel0..3(recommended)
Prefer:
vec4 v = SG_TEXELFETCH0(ivec2(x, y));
instead of hardcoding textureSize constants.
4. Port Shadertoy with Copilot prompts (recommended) #
This repo includes two porting prompts:
.github/prompts/port_shader.prompt.md: general porting (may not use feedback).github/prompts/port_shader_float.prompt.md: multi-pass +sg_feedback_rgba8spec (recommended for games/state machines)
4.1 Before you start
-
Ensure shader assets are declared under
flutter: shaders:in the consuming app (usuallyexample/)pubspec.yaml. -
Identify Shadertoy passes:
- BufferA/BufferB/BufferC/BufferD
- Image (main output)
- Identify each pass input channel (iChannel0..):
- which buffer output (from which pass)
- which image asset
- keyboard texture input (provided by this project)
4.2 Shader file structure (must follow)
For each pass file:
- add a porting log header (optional but recommended)
- the first include must be:
#include <../common/common_header.frag>
-
declare needed
uniform sampler2D iChannelN; -
if the pass uses
sg_feedback_rgba8, then include:
#include <../common/sg_feedback_rgba8.frag>
- at the end of file include:
#include <../common/main_shadertoy.frag>
4.3 Common SkSL incompatibilities (minimal fixes)
- do not pass
sampler2Das a function parameter (use macros) - avoid global
const int[] = int[](...)initialization (use if-chain getters) - avoid bit ops (
>> & | ^) and int%(usefloor/mod/powalternatives) - avoid native
texelFetch(useSG_TEXELFETCH*) - explicitly initialize locals (SkSL is more sensitive)
4.4 Dart-side wiring (minimal multi-pass + feedback scheme)
Typical pipeline (avoid read/write conflicts):
- BufferA: read previous frame feedback, update state
- BufferB: passthrough (copy BufferA output)
- Main: render by reading BufferB only
Key points:
- data buffers must set
fixedOutputSizeto the physical size (for exampleSize(VSIZE.x*4, VSIZE.y)) - feedback via
.feedback()or.feed(buffer, usePreviousFrame: true) - if you need surface-sized
iResolution/iMousewhile rendering to a tiny fixedOutputSize, enableuseSurfaceSizeForIResolution = trueon that buffer
Note: once
useSurfaceSizeForIResolutionis enabled, don't derive packing ratios fromiResolution(it no longer equals the render target size).
5. Minimal snippets #
5.1 Wrap sampling
#include <../common/common_header.frag>
uniform sampler2D iChannel0;
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
fragColor = SG_TEX0(iChannel0, uv);
}
#include <../common/main_shadertoy.frag>
5.2 Keyboard texture (prefer SG_TEXELFETCH*)
// Assume iChannel1 is the keyboard texture
float keyDown(int keyCode) {
return SG_TEXELFETCH1(ivec2(keyCode, 0)).x;
}
6. Troubleshooting checklist #
-
visual “split/jitter/flicker”:
- lane crosstalk? (try writing
vec4(v,v,v,v)and averaging reads) - wrote NaN/Inf? (check division by 0 / normalize / log)
- lane crosstalk? (try writing
-
only a corner shows / stretched:
- did you multiply
iResolutionby dpr/scale again? (hereiResolutionis already in pixels) - enabled
useSurfaceSizeForIResolutionwhile actually rendering tofixedOutputSize? (coordinate mismatch)
- did you multiply
-
wrap not working:
- are you sampling via
SG_TEX0/1/2/3orsg_wrapUv? (don't usetexture(iChannelN, uv)directly)
- are you sampling via
7. References: prompt files #
.github/prompts/port_shader.prompt.md.github/prompts/port_shader_float.prompt.md
toImageSync memory leak #
toImageSync retains display list which can lead to surprising memory retention
I hit a pitfall here: on Flutter 3.38.5 (macOS), toImageSync can still show
obvious memory growth.
In my local tests, after running for a while, the app would keep consuming
physical memory and start using Swap. The peak usage became extremely large
(over 200GB).
Current mitigation in this repo:
- Use async
toImage()(avoid the riskytoImageSyncpath) - But we cannot trigger a conversion every frame, otherwise the overhead is huge
- Use a Ticker / throttling strategy: only schedule the next update after a “new frame image is ready”
Copilot #
To be honest, I maintain too many projects, and many projects I care about are in a semi-maintained state.
So for this project, I used a lot of AI to help build it — mostly GPT-5.2. Also because I do a fair amount of open source, I get some free quota every month. I love open source.
But I try to keep myself in the driver seat rather than letting it drive me. I’m not very familiar with shader-related topics, and most of the shader-side code was written by it.
I was responsible for organizing things and writing prompts. Even though the AI did a lot, debugging and validation still took significant time.
The Dart-side design is almost entirely based on my own ideas.
I try to ensure:
- Simple and convenient usage
- Powerful capabilities
- Reasonable design
- Readable code
- Lots of Chinese/English comments