Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/interactive_map.dart
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,8 @@ class _InteractiveMapState extends ConsumerState<InteractiveMap> {
child: Padding(
padding: const EdgeInsets.all(4.0),
child:
RepaintBoundary(child: DotGrid()),
RepaintBoundary(
child: DotGrid(followsEditorZoom: true)),
),
),
),
Expand Down
117 changes: 105 additions & 12 deletions lib/widgets/dot_painter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,42 +48,103 @@
// return false;
// }
// }
import 'dart:ui' show PointMode;
import 'dart:ui' as ui show FragmentProgram, FragmentShader, PointMode;

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icarus/const/settings.dart';
import 'package:icarus/providers/screen_zoom_provider.dart';
import 'package:icarus/providers/user_preferences_provider.dart';

class DotGrid extends ConsumerWidget {
class DotGrid extends ConsumerStatefulWidget {
const DotGrid({
super.key,
this.isScreenshot = false,
this.opacity,
this.followsEditorZoom = false,
});
final bool isScreenshot;

/// Whether the grid sits on the editor's zoomable canvas. On the web its
/// dots then keep edges one device pixel wide at the editor's zoom.
final bool followsEditorZoom;

/// Offscreen captures pass the opacity in because their isolated provider
/// container doesn't read Hive. Everywhere else the user's setting applies.
final double? opacity;

@override
Widget build(BuildContext context, WidgetRef ref) {
final double opacity = this.opacity ??
ConsumerState<DotGrid> createState() => _DotGridState();
}

class _DotGridState extends ConsumerState<DotGrid> {
// The web redraws every point of the grid on every frame, which was most
// of the editor's raster time while dragging; there one shader draws the
// lattice instead. Desktop keeps the grid's pixels between frames, so it
// keeps the points, as do screenshots. Until the shader loads, the points
// draw.
static final _lattice = ValueNotifier<ui.FragmentProgram?>(null);
static bool _loading = false;

// This grid's shader, kept for its lifetime: each holds uniforms that are
// only freed by dispose. A recorded picture reads the shader's current
// uniforms when drawn, which is safe because the grid only ever shows the
// picture it painted last.
ui.FragmentShader? _shader;

bool get _usesShader => kIsWeb && !widget.isScreenshot;

static void _load() {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if (_loading) return;
_loading = true;
// A failed load leaves the points drawing, and the next build tries again.
ui.FragmentProgram.fromAsset('shaders/dot_lattice.frag').then(
(program) => _lattice.value = program,
onError: (_) => _loading = false);
}

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

@override
Widget build(BuildContext context) {
final double opacity = widget.opacity ??
ref.watch(
appPreferencesProvider.select((prefs) => prefs.backgroundDotOpacity),
);
return CustomPaint(
painter: DotPainter(isScreenshot: isScreenshot, opacity: opacity),
if (!_usesShader) {
return CustomPaint(painter: DotPainter(opacity: opacity));
}
_load();
final zoom = widget.followsEditorZoom ? ref.watch(screenZoomProvider) : 1.0;
final pixel = 1 / (MediaQuery.devicePixelRatioOf(context) * zoom);
return ValueListenableBuilder(
valueListenable: _lattice,
builder: (context, program, _) {
if (program != null) _shader ??= program.fragmentShader();
return CustomPaint(
painter: DotPainter(opacity: opacity, shader: _shader, pixel: pixel),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
);
},
);
}
}

class DotPainter extends CustomPainter {
DotPainter({required this.isScreenshot, required this.opacity});
DotPainter({required this.opacity, this.shader, this.pixel = 1});

final bool isScreenshot;
final double opacity;

/// dot_lattice.frag, to draw the grid with in place of its points.
final ui.FragmentShader? shader;

/// One device pixel in the grid's own units, for [shader].
final double pixel;

Size? _cachedSize;
List<Offset> _cachedPoints = const [];

Expand All @@ -93,14 +154,45 @@ class DotPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
if (opacity <= 0) return;
final color = Settings.tacticalVioletTheme.border.withValues(
alpha: 0.7 * opacity,
);
final shader = this.shader;
if (shader != null) {
_paintLattice(canvas, size, shader, color);
return;
}

final paint = Paint()
..color =
Settings.tacticalVioletTheme.border.withValues(alpha: 0.7 * opacity)
..color = color
..strokeWidth = dotSize
..strokeCap = StrokeCap.round;

canvas.drawPoints(PointMode.points, _pointsFor(size), paint);
canvas.drawPoints(ui.PointMode.points, _pointsFor(size), paint);
}

/// The same lattice as [_pointsFor], drawn by dot_lattice.frag.
void _paintLattice(
Canvas canvas, Size size, ui.FragmentShader shader, Color color) {
final rows = (size.height / dotSpacing).ceil() + 1;
final columns = (size.width / dotSpacing).ceil() + 1;
var i = 0;
shader
..setFloat(i++, columns > 1 ? size.width / (columns - 1) : 0)
..setFloat(i++, rows > 1 ? size.height / (rows - 1) : 0)
..setFloat(i++, columns - 1.0)
..setFloat(i++, rows - 1.0)
..setFloat(i++, dotSize / 2)
..setFloat(i++, pixel)
..setFloat(i++, color.r)
..setFloat(i++, color.g)
..setFloat(i++, color.b)
..setFloat(i++, color.a);
// The outer dots straddle the edges, as points do.
canvas.drawRect(
(Offset.zero & size).inflate(dotSize),
Paint()..shader = shader,
);
}

List<Offset> _pointsFor(Size size) {
Expand Down Expand Up @@ -141,6 +233,7 @@ class DotPainter extends CustomPainter {
@override
bool shouldRepaint(DotPainter oldDelegate) {
return oldDelegate.opacity != opacity ||
oldDelegate.isScreenshot != isScreenshot;
oldDelegate.shader != shader ||
oldDelegate.pixel != pixel;
}
}
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ msix_config:
flutter:
shaders:
- shaders/dot_grid.frag
- shaders/dot_lattice.frag
- shaders/dither_fire.frag
fonts:
- family: CustomIcons
Expand Down
32 changes: 32 additions & 0 deletions shaders/dot_lattice.frag
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#version 460 core

// The background dot grid, as DotPainter lays it out: a lattice whose
// spacing is stretched so the outer dots sit on the edges. One shader over
// one rectangle instead of one point per dot, because the web redraws every
// point of the grid on every frame.

precision highp float;

#include <flutter/runtime_effect.glsl>

uniform vec2 uSpacing; // distance between dot centres on each axis
uniform vec2 uLast; // index of the last dot on each axis
uniform float uRadius; // dot radius
uniform float uPixel; // one device pixel, in the same units
uniform vec4 uColor; // dot colour (straight alpha)

out vec4 fragColor;

void main() {
vec2 p = FlutterFragCoord().xy;

// The nearest dot that exists: the lattice stops at the edges.
vec2 index = clamp(floor(p / uSpacing + 0.5), vec2(0.0), uLast);
float distToDot = length(p - index * uSpacing);

// About one device pixel of antialiasing at any zoom.
float mask = clamp((uRadius - distToDot) / uPixel + 0.5, 0.0, 1.0);

float a = uColor.a * mask;
fragColor = vec4(uColor.rgb * a, a); // premultiplied alpha
}
52 changes: 52 additions & 0 deletions test/dot_lattice_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:icarus/widgets/dot_painter.dart';

// Its own file: the shader loads on the real event loop.
void main() {
Future<Uint8List> paint(WidgetTester tester, DotPainter painter, Size size) =>
tester.runAsync(() async {
final recorder = ui.PictureRecorder();
painter.paint(Canvas(recorder), size);
final image = await recorder
.endRecording()
.toImage(size.width.toInt(), size.height.toInt());
final bytes = await image.toByteData();
return bytes!.buffer.asUint8List();
}).then((bytes) => bytes!);

testWidgets('the web grid shader draws the dots the points do',
(tester) async {
final program = await tester.runAsync(
() => ui.FragmentProgram.fromAsset('shaders/dot_lattice.frag'));
final shader = program!.fragmentShader();
addTearDown(shader.dispose);
// Sizes whose spacing stretches by different amounts on each axis.
for (final size in const [Size(300, 200), Size(257, 133)]) {
final points = await paint(tester, DotPainter(opacity: 1), size);
final lattice =
await paint(tester, DotPainter(opacity: 1, shader: shader), size);
final width = size.width.toInt(), height = size.height.toInt();
var worst = 0, ink = 0, latticeInk = 0;
// Away from the edges: rounding can drop the points' last row or
// column, which the shader always draws.
for (var y = 4; y < height - 4; y++) {
for (var x = 4; x < width - 4; x++) {
final i = (y * width + x) * 4;
for (var c = 0; c < 4; c++) {
final gap = (points[i + c] - lattice[i + c]).abs();
if (gap > worst) worst = gap;
}
ink += points[i + 3];
latticeInk += lattice[i + 3];
}
}
// The same dots in the same places, antialiased a little differently.
expect(worst, lessThanOrEqualTo(48), reason: '$size');
expect(latticeInk / ink, inInclusiveRange(0.92, 1.1), reason: '$size');
}
});
}
Loading