From 99b947fa0f5d9fe923d9c5a21361bb9faf7d6532 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 5 Oct 2026 21:47:48 -0400 Subject: [PATCH 1/4] Draw the editor's dot grid with one shader on the web The web engine redraws every recorded point on every frame: it keeps no pixels between frames the way desktop does. The editor's background grid is about 14,700 points, and while anything was dragged it was most of the frame's raster time. On the web the grid is now one rectangle drawn by a small lattice shader, with the same stretched spacing, dot size and colour and antialiasing one device pixel wide at the editor's zoom. Desktop, and screenshots, keep the points. Dragging a view cone in the signed-in web app went from 96-100 fps with #251 (83-84 before it) to 139 fps, with frames over 16.8 ms falling from hundreds per drag to about ten. At 1:1 and zoomed in, the grid matches the points to within 3/255 per pixel. Found by Astra's raster investigation. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/interactive_map.dart | 3 +- lib/widgets/dot_painter.dart | 87 +++++++++++++++++++++++++++++++++--- pubspec.yaml | 1 + shaders/dot_lattice.frag | 33 ++++++++++++++ 4 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 shaders/dot_lattice.frag diff --git a/lib/interactive_map.dart b/lib/interactive_map.dart index fb4b264a4..8df28aea3 100644 --- a/lib/interactive_map.dart +++ b/lib/interactive_map.dart @@ -263,7 +263,8 @@ class _InteractiveMapState extends ConsumerState { child: Padding( padding: const EdgeInsets.all(4.0), child: - RepaintBoundary(child: DotGrid()), + RepaintBoundary( + child: DotGrid(followsEditorZoom: true)), ), ), ), diff --git a/lib/widgets/dot_painter.dart b/lib/widgets/dot_painter.dart index 2ffb466cc..b9fd47fac 100644 --- a/lib/widgets/dot_painter.dart +++ b/lib/widgets/dot_painter.dart @@ -48,11 +48,13 @@ // 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 { @@ -60,9 +62,14 @@ class DotGrid extends ConsumerWidget { super.key, this.isScreenshot = false, this.opacity, + this.followsEditorZoom = false, }); final bool isScreenshot; + /// Whether the grid sits on the editor's zoomable canvas. 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; @@ -74,16 +81,37 @@ class DotGrid extends ConsumerWidget { appPreferencesProvider.select((prefs) => prefs.backgroundDotOpacity), ); return CustomPaint( - painter: DotPainter(isScreenshot: isScreenshot, opacity: opacity), + painter: DotPainter( + isScreenshot: isScreenshot, + opacity: opacity, + pixel: 1 / + (MediaQuery.devicePixelRatioOf(context) * + (followsEditorZoom ? ref.watch(screenZoomProvider) : 1)), + ), ); } } class DotPainter extends CustomPainter { - DotPainter({required this.isScreenshot, required this.opacity}); + DotPainter({ + required this.isScreenshot, + required this.opacity, + this.pixel = 1, + }) : super(repaint: _lattice); final bool isScreenshot; final double opacity; + + /// One device pixel in the grid's own units. + final double pixel; + + // 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. Until the shader loads, the points draw. + static final _lattice = ValueNotifier(null); + static bool _loading = false; + ui.FragmentShader? _shader; Size? _cachedSize; List _cachedPoints = const []; @@ -93,14 +121,58 @@ 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, + ); + if (kIsWeb && !isScreenshot) { + final program = _lattice.value; + if (program != null) { + _paintLattice(canvas, size, program, color); + return; + } + if (!_loading) { + _loading = true; + ui.FragmentProgram.fromAsset( + 'shaders/dot_lattice.frag', + ).then((program) => _lattice.value = program, onError: (_) {}); + } + } 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.FragmentProgram program, + Color color, + ) { + final rows = (size.height / dotSpacing).ceil() + 1; + final columns = (size.width / dotSpacing).ceil() + 1; + final shader = _shader ??= program.fragmentShader(); + 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 _pointsFor(Size size) { @@ -141,6 +213,7 @@ class DotPainter extends CustomPainter { @override bool shouldRepaint(DotPainter oldDelegate) { return oldDelegate.opacity != opacity || - oldDelegate.isScreenshot != isScreenshot; + oldDelegate.isScreenshot != isScreenshot || + oldDelegate.pixel != pixel; } } diff --git a/pubspec.yaml b/pubspec.yaml index 8d0d7033e..cee693554 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -96,6 +96,7 @@ msix_config: flutter: shaders: - shaders/dot_grid.frag + - shaders/dot_lattice.frag - shaders/dither_fire.frag fonts: - family: CustomIcons diff --git a/shaders/dot_lattice.frag b/shaders/dot_lattice.frag new file mode 100644 index 000000000..ca3235760 --- /dev/null +++ b/shaders/dot_lattice.frag @@ -0,0 +1,33 @@ +#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 + +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 = 1.0 - smoothstep(uRadius - 0.5 * uPixel, + uRadius + 0.5 * uPixel, distToDot); + + float a = uColor.a * mask; + fragColor = vec4(uColor.rgb * a, a); // premultiplied alpha +} From f7ad2eaa4d55360db2676c9b10aefbb63aa18b27 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 5 Oct 2026 22:00:55 -0400 Subject: [PATCH 2/4] Keep one dot-grid shader per grid, and the points path untouched A shader was made for every painter and never disposed, and its uniforms are only freed by dispose: zooming replaced the painter each step, so each step leaked one. The grid's state now owns a single shader and disposes it when the grid goes. Only the web reads the editor's zoom, so desktop records its points once again rather than on every zoom step. Found in review by Astra. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/widgets/dot_painter.dart | 110 ++++++++++++++++++++--------------- 1 file changed, 64 insertions(+), 46 deletions(-) diff --git a/lib/widgets/dot_painter.dart b/lib/widgets/dot_painter.dart index b9fd47fac..bacbc8bf2 100644 --- a/lib/widgets/dot_painter.dart +++ b/lib/widgets/dot_painter.dart @@ -57,7 +57,7 @@ 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, @@ -66,8 +66,8 @@ class DotGrid extends ConsumerWidget { }); final bool isScreenshot; - /// Whether the grid sits on the editor's zoomable canvas. Its dots then - /// keep edges one device pixel wide at the editor's zoom. + /// 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 @@ -75,43 +75,74 @@ class DotGrid extends ConsumerWidget { final double? opacity; @override - Widget build(BuildContext context, WidgetRef ref) { - final double opacity = this.opacity ?? + ConsumerState createState() => _DotGridState(); +} + +class _DotGridState extends ConsumerState { + // 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(null); + static bool _loading = false; + + // This grid's shader, kept for its lifetime: each holds uniforms that are + // only freed by dispose. + ui.FragmentShader? _shader; + + bool get _usesShader => kIsWeb && !widget.isScreenshot; + + @override + void initState() { + super.initState(); + if (_usesShader && !_loading) { + _loading = true; + ui.FragmentProgram.fromAsset('shaders/dot_lattice.frag') + .then((program) => _lattice.value = program, onError: (_) {}); + } + } + + @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, - pixel: 1 / - (MediaQuery.devicePixelRatioOf(context) * - (followsEditorZoom ? ref.watch(screenZoomProvider) : 1)), - ), + if (!_usesShader) { + return CustomPaint(painter: DotPainter(opacity: opacity)); + } + 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), + ); + }, ); } } class DotPainter extends CustomPainter { - DotPainter({ - required this.isScreenshot, - required this.opacity, - this.pixel = 1, - }) : super(repaint: _lattice); + DotPainter({required this.opacity, this.shader, this.pixel = 1}); - final bool isScreenshot; final double opacity; - /// One device pixel in the grid's own units. + /// 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; - // 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. Until the shader loads, the points draw. - static final _lattice = ValueNotifier(null); - static bool _loading = false; - ui.FragmentShader? _shader; Size? _cachedSize; List _cachedPoints = const []; @@ -124,18 +155,10 @@ class DotPainter extends CustomPainter { final color = Settings.tacticalVioletTheme.border.withValues( alpha: 0.7 * opacity, ); - if (kIsWeb && !isScreenshot) { - final program = _lattice.value; - if (program != null) { - _paintLattice(canvas, size, program, color); - return; - } - if (!_loading) { - _loading = true; - ui.FragmentProgram.fromAsset( - 'shaders/dot_lattice.frag', - ).then((program) => _lattice.value = program, onError: (_) {}); - } + final shader = this.shader; + if (shader != null) { + _paintLattice(canvas, size, shader, color); + return; } final paint = Paint() @@ -148,14 +171,9 @@ class DotPainter extends CustomPainter { /// The same lattice as [_pointsFor], drawn by dot_lattice.frag. void _paintLattice( - Canvas canvas, - Size size, - ui.FragmentProgram program, - Color color, - ) { + Canvas canvas, Size size, ui.FragmentShader shader, Color color) { final rows = (size.height / dotSpacing).ceil() + 1; final columns = (size.width / dotSpacing).ceil() + 1; - final shader = _shader ??= program.fragmentShader(); var i = 0; shader ..setFloat(i++, columns > 1 ? size.width / (columns - 1) : 0) @@ -213,7 +231,7 @@ class DotPainter extends CustomPainter { @override bool shouldRepaint(DotPainter oldDelegate) { return oldDelegate.opacity != opacity || - oldDelegate.isScreenshot != isScreenshot || + oldDelegate.shader != shader || oldDelegate.pixel != pixel; } } From 5421bbead6d1cd86d8867573598839967938f155 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 5 Oct 2026 22:14:26 -0400 Subject: [PATCH 3/4] Start loading the dot-grid shader from build So a grid that becomes eligible after it mounts still gets the shader, and note why one shader per grid is safe to reuse across its pictures. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/widgets/dot_painter.dart | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/widgets/dot_painter.dart b/lib/widgets/dot_painter.dart index bacbc8bf2..a358c6424 100644 --- a/lib/widgets/dot_painter.dart +++ b/lib/widgets/dot_painter.dart @@ -88,19 +88,18 @@ class _DotGridState extends ConsumerState { static bool _loading = false; // This grid's shader, kept for its lifetime: each holds uniforms that are - // only freed by dispose. + // 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; - @override - void initState() { - super.initState(); - if (_usesShader && !_loading) { - _loading = true; - ui.FragmentProgram.fromAsset('shaders/dot_lattice.frag') - .then((program) => _lattice.value = program, onError: (_) {}); - } + static void _load() { + if (_loading) return; + _loading = true; + ui.FragmentProgram.fromAsset('shaders/dot_lattice.frag') + .then((program) => _lattice.value = program, onError: (_) {}); } @override @@ -118,6 +117,7 @@ class _DotGridState extends ConsumerState { 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( From 4ab28162baad796bc911b6051593eedbd580eb23 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 5 Oct 2026 22:40:39 -0400 Subject: [PATCH 4/4] Retry a failed grid shader load, and test the shader against the points A failed load left the loading flag set, so the grid drew points until the page reloaded; the next build now tries again. The shader's dot edge is now a linear ramp of about one device pixel, closer to the points' coverage than smoothstep was. dot_lattice_test paints the grid both ways in the test runner and holds the shader to the same dots in the same places: no pixel more than 48/255 apart away from the edges, total ink within 8% (both found by Greptile). Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/widgets/dot_painter.dart | 6 +++-- shaders/dot_lattice.frag | 3 +-- test/dot_lattice_test.dart | 52 ++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 test/dot_lattice_test.dart diff --git a/lib/widgets/dot_painter.dart b/lib/widgets/dot_painter.dart index a358c6424..5189d0131 100644 --- a/lib/widgets/dot_painter.dart +++ b/lib/widgets/dot_painter.dart @@ -98,8 +98,10 @@ class _DotGridState extends ConsumerState { static void _load() { if (_loading) return; _loading = true; - ui.FragmentProgram.fromAsset('shaders/dot_lattice.frag') - .then((program) => _lattice.value = program, onError: (_) {}); + // 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 diff --git a/shaders/dot_lattice.frag b/shaders/dot_lattice.frag index ca3235760..599e5e07f 100644 --- a/shaders/dot_lattice.frag +++ b/shaders/dot_lattice.frag @@ -25,8 +25,7 @@ void main() { float distToDot = length(p - index * uSpacing); // About one device pixel of antialiasing at any zoom. - float mask = 1.0 - smoothstep(uRadius - 0.5 * uPixel, - uRadius + 0.5 * uPixel, distToDot); + 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 diff --git a/test/dot_lattice_test.dart b/test/dot_lattice_test.dart new file mode 100644 index 000000000..14704f4ea --- /dev/null +++ b/test/dot_lattice_test.dart @@ -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 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'); + } + }); +}