Skip to content

Instantly share code, notes, and snippets.

@ardera
Last active May 27, 2022 18:43
Show Gist options
  • Save ardera/bbb10fe293e0626e6bc722d89cf05142 to your computer and use it in GitHub Desktop.
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)
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();
}
},
);
}
}
#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