Last active
May 27, 2022 18:43
-
-
Save ardera/bbb10fe293e0626e6bc722d89cf05142 to your computer and use it in GitHub Desktop.
Simple dithering in flutter using a RepaintBoundary to capture the screen contents and a GLSL shader and some blue noise picture to apply the dithering. (Also contains another approach using colorfilters which I couldn't get fully working)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:async'; | |
import 'dart:typed_data'; | |
import 'dart:ui' as ui; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/rendering.dart'; | |
import 'package:flutter/scheduler.dart'; | |
import 'package:flutter/services.dart'; | |
import 'package:tuple/tuple.dart'; | |
class Dithering extends StatefulWidget { | |
const Dithering({required this.child, Key? key}) : super(key: key); | |
final Widget child; | |
@override | |
State<Dithering> createState() => _DitheringState(); | |
} | |
Future<Tuple2<A, B>> awaitMultiple2<A, B>(Future<A> a, Future<B> b) async { | |
final result = await Future.wait([a, b]); | |
return Tuple2<A, B>(result[0] as A, result[1] as B); | |
} | |
Future<Tuple3<A, B, C>> awaitMultiple3<A, B, C>(Future<A> a, Future<B> b, Future<C> c) async { | |
final result = await Future.wait([a, b, c]); | |
return Tuple3<A, B, C>(result[0] as A, result[1] as B, result[2] as C); | |
} | |
Future<ui.Image> loadAssetImage( | |
String assetName, { | |
AssetBundle? bundle, | |
double? devicePixelRatio, | |
Locale? locale, | |
TextDirection? textDirection, | |
Size? size, | |
TargetPlatform? platform, | |
}) { | |
final configuration = ImageConfiguration( | |
bundle: bundle ?? rootBundle, | |
devicePixelRatio: devicePixelRatio, | |
locale: locale, | |
textDirection: textDirection, | |
size: size, | |
platform: platform, | |
); | |
final completer = Completer<ui.Image>(); | |
final stream = AssetImage(assetName).resolve(configuration); | |
late ImageStreamListener listener; | |
void unlisten() { | |
Future<void>.delayed(Duration.zero).then((_) => stream.removeListener(listener)); | |
} | |
listener = ImageStreamListener( | |
(info, x) { | |
completer.complete(Future.value(info.image)); | |
unlisten(); | |
}, | |
onError: (exception, stackTrace) { | |
completer.completeError(exception, stackTrace); | |
unlisten(); | |
}, | |
); | |
stream.addListener(listener); | |
return completer.future; | |
} | |
Future<ui.FragmentProgram> loadShader( | |
String assetName, { | |
AssetBundle? bundle, | |
bool debugPrint = false, | |
}) async { | |
bundle ??= rootBundle; | |
final byteData = await bundle.load(assetName); | |
assert(byteData.offsetInBytes == 0 && byteData.lengthInBytes == byteData.buffer.lengthInBytes); | |
final buffer = byteData.buffer; | |
return ui.FragmentProgram.compile(spirv: buffer, debugPrint: debugPrint); | |
} | |
Future<ui.Image> loadImageFromPixels( | |
Uint8List pixels, | |
int width, | |
int height, | |
ui.PixelFormat format, { | |
int? rowBytes, | |
int? targetWidth, | |
int? targetHeight, | |
bool allowUpscaling = true, | |
}) { | |
final completer = Completer<ui.Image>(); | |
ui.decodeImageFromPixels( | |
pixels, | |
width, | |
height, | |
format, | |
(img) => completer.complete(img), | |
rowBytes: rowBytes, | |
targetWidth: targetWidth, | |
targetHeight: targetHeight, | |
allowUpscaling: allowUpscaling, | |
); | |
return completer.future; | |
} | |
class DitherPainter extends CustomPainter { | |
DitherPainter({required this.image, required this.noise, required this.shaderProgram}); | |
final ui.Image image; | |
final ui.Image noise; | |
final ui.FragmentProgram shaderProgram; | |
static final identityMatrix = Matrix4.identity().storage; | |
@override | |
void paint(ui.Canvas canvas, ui.Size size) { | |
final shader = shaderProgram.shader( | |
floatUniforms: Float32List.fromList([ | |
image.width.toDouble(), | |
image.height.toDouble(), | |
noise.width.toDouble(), | |
noise.height.toDouble(), | |
]), | |
samplerUniforms: [ | |
ui.ImageShader( | |
image, | |
TileMode.clamp, | |
TileMode.clamp, | |
identityMatrix, | |
), | |
ui.ImageShader( | |
noise, | |
TileMode.repeated, | |
TileMode.repeated, | |
identityMatrix, | |
), | |
], | |
); | |
canvas.drawRect( | |
Offset.zero & size, | |
Paint() | |
..style = PaintingStyle.fill | |
..color = Colors.red | |
..blendMode = BlendMode.src | |
..shader = shader, | |
); | |
} | |
@override | |
bool shouldRepaint(covariant DitherPainter oldDelegate) { | |
return oldDelegate.image != image || | |
oldDelegate.noise != noise || | |
oldDelegate.shaderProgram != shaderProgram; | |
} | |
} | |
class _DitheringState extends State<Dithering> with SingleTickerProviderStateMixin { | |
Future<Tuple2<ui.FragmentProgram, ui.Image>>? resources; | |
final capturedImage = ValueNotifier<ui.Image?>(null); | |
late Ticker ticker; | |
late GlobalKey repaintBoundary; | |
final Float64List identityMatrix = Matrix4.identity().storage; | |
Future<ui.Image> invertImage(ui.Image img) async { | |
final data = await img.toByteData(); | |
data!; | |
final inBytes = Uint32List.view( | |
data.buffer, | |
data.offsetInBytes, | |
data.lengthInBytes ~/ Uint32List.bytesPerElement, | |
); | |
final outBytesUint8 = Uint8List(data.lengthInBytes); | |
final outBytes = Uint32List.view( | |
outBytesUint8.buffer, | |
outBytesUint8.offsetInBytes, | |
outBytesUint8.lengthInBytes ~/ Uint32List.bytesPerElement, | |
); | |
for (int i = 0; i < inBytes.length; i++) { | |
outBytes[i] = inBytes[i] & 0xFFFFFFFF; | |
} | |
return loadImageFromPixels( | |
outBytesUint8, | |
img.width, | |
img.height, | |
ui.PixelFormat.rgba8888, | |
); | |
} | |
void takeSnapshot() { | |
final render = repaintBoundary.currentContext!.findRenderObject() as RenderRepaintBoundary; | |
render.toImage().then((img) => capturedImage.value = img); | |
} | |
@override | |
void initState() { | |
super.initState(); | |
repaintBoundary = GlobalKey(); | |
ticker = createTicker((elapsed) { | |
if (repaintBoundary.currentContext != null) { | |
final renderObject = | |
repaintBoundary.currentContext!.findRenderObject() as RenderRepaintBoundary; | |
if (renderObject.debugNeedsPaint) { | |
Future<void>.delayed(Duration.zero).then((_) => takeSnapshot()); | |
} else { | |
takeSnapshot(); | |
} | |
} else { | |
print('repaintBoundary.currentContext == null'); | |
} | |
}); | |
ticker.start(); | |
} | |
@override | |
void dispose() { | |
super.dispose(); | |
ticker.stop(); | |
} | |
@override | |
void didChangeDependencies() { | |
resources ??= awaitMultiple2( | |
loadShader( | |
'dither/dither.sprv', | |
bundle: DefaultAssetBundle.of(context), | |
debugPrint: true, | |
), | |
loadAssetImage( | |
'dither/LDR_LLL1_0.png', | |
bundle: DefaultAssetBundle.of(context), | |
devicePixelRatio: MediaQuery.maybeOf(context)?.devicePixelRatio ?? 1.0, | |
locale: Localizations.maybeLocaleOf(context), | |
textDirection: Directionality.maybeOf(context), | |
platform: defaultTargetPlatform, | |
), | |
); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return FutureBuilder<Tuple2<ui.FragmentProgram, ui.Image>>( | |
future: resources!, | |
builder: (context, snapshot) { | |
if (snapshot.hasData) { | |
final program = snapshot.data!.item1; | |
final noise = snapshot.data!.item2; | |
return ValueListenableBuilder<ui.Image?>( | |
valueListenable: capturedImage, | |
builder: (context, img, child) { | |
if (img != null) { | |
return CustomPaint( | |
foregroundPainter: DitherPainter( | |
shaderProgram: program, | |
image: img, | |
noise: noise, | |
), | |
child: child, | |
); | |
} else { | |
return child!; | |
} | |
}, | |
child: RepaintBoundary( | |
key: repaintBoundary, | |
child: widget.child, | |
), | |
); | |
} else if (snapshot.hasError) { | |
return ErrorWidget( | |
FlutterErrorDetails( | |
exception: snapshot.error!, | |
stack: snapshot.stackTrace!, | |
library: 'dithering', | |
).toString(), | |
); | |
} else { | |
return widget.child; | |
} | |
}, | |
); | |
} | |
static const monochromeRGB = [0.2126, 0.7152, 0.0722, 0.0, 0.0]; | |
static const monochromeAlpha = [0.0, 0.0, 0.0, 1.0, 0.0]; | |
static const monochrome = ColorFilter.matrix(<double>[ | |
...monochromeRGB, | |
...monochromeRGB, | |
...monochromeRGB, | |
...monochromeAlpha, | |
]); | |
static const threshold1RGB = [1 / 220, 0.0, 0.0, 0.0, 0.0]; | |
static const threshold1Alpha = [0.0, 0.0, 0.0, 1.0, 0.0]; | |
static const threshold1 = ColorFilter.matrix(<double>[ | |
...threshold1RGB, | |
...threshold1RGB, | |
...threshold1RGB, | |
...threshold1Alpha, | |
]); | |
static const threshold2RGB = [220.0, 0.0, 0.0, 0.0, 0.0]; | |
static const threshold2Alpha = [0.0, 0.0, 0.0, 1.0, 0.0]; | |
static const threshold2 = ColorFilter.matrix(<double>[ | |
...threshold2RGB, | |
...threshold2RGB, | |
...threshold2RGB, | |
...threshold2Alpha, | |
]); | |
@override | |
Widget build2(BuildContext context) { | |
return FutureBuilder<Tuple2<ui.FragmentProgram, ui.Image>>( | |
future: resources, | |
builder: (context, snapshot) { | |
if (snapshot.hasData) { | |
final noise = snapshot.data!.item2; | |
return ColorFiltered( | |
colorFilter: threshold2, | |
child: ColorFiltered( | |
colorFilter: threshold1, | |
child: ShaderMask( | |
shaderCallback: (rect) { | |
return ui.ImageShader( | |
noise, | |
TileMode.repeated, | |
TileMode.repeated, | |
identityMatrix, | |
); | |
}, | |
child: ColorFiltered( | |
colorFilter: monochrome, | |
child: widget.child, | |
), | |
), | |
), | |
); | |
} else if (snapshot.hasError) { | |
final details = FlutterErrorDetails( | |
exception: snapshot.error!, | |
stack: snapshot.stackTrace!, | |
library: 'dithering', | |
); | |
return ErrorWidget.withDetails(message: details.toString()); | |
} else { | |
return const CircularProgressIndicator(); | |
} | |
}, | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#version 460 | |
precision mediump float; | |
layout(location = 0)out vec4 fragColor; | |
layout(location = 0)uniform sampler2D main_tex; | |
layout(location = 1)uniform sampler2D noise; | |
layout(location = 2)uniform vec2 main_size; | |
layout(location = 3)uniform vec2 noise_size; | |
void main() { | |
vec3 col = texture(main_tex, gl_FragCoord.xy / vec2(main_size)).xyz; | |
float lum = dot(col, vec3(0.299, 0.587, 0.114)); | |
vec2 noise_uv = gl_FragCoord.xy / vec2(noise_size); | |
vec4 threshold = texture(noise, noise_uv); | |
float threshold_lum = dot(threshold, vec4(0.299, 0.587, 0.114, 0.0)); | |
float val = lum < threshold_lum ? 0.0 : 1.0; | |
fragColor = vec4(vec3(val), 1.0f); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment