perf(tv): rasterize axis-aligned gradients into strip textures

Skia's dithered gradient shaders cost ~10ms per full-screen pass on
Mali-class TV GPUs: the two spotlight scrims alone were ~20ms of a 27ms
raster frame, while flat blended quads at the same coverage are ~free.
Bake axis-aligned LinearGradients once into cached 1x1024 premultiplied
strip textures drawn as stretched quads (shader fallback for unsupported
shapes and the first frame). Converted the spotlight, TV detail backdrop,
app-bar scrims, rail bleed, person-card overlay, and the video-controls
scrim, which now also keeps one widget type across hasFrame flips so the
controls subtree survives in-place source switches.

Scripted-browse on a Mali-G31 box: draw p50 27.5ms -> 7.1ms, swap block
20.3ms -> 0.9ms, janky draws 69% -> 2%; screenshots pixel-identical.
This commit is contained in:
edde746
2026-07-02 11:41:25 +02:00
parent 1059658515
commit b08b3b846a
7 changed files with 376 additions and 72 deletions
+12 -13
View File
@@ -22,6 +22,7 @@ import '../media/media_hub.dart';
import '../utils/media_image_helper.dart';
import '../utils/content_utils.dart';
import '../widgets/optimized_media_image.dart' show blurArtwork;
import '../widgets/rasterized_gradient.dart';
import '../providers/discover_provider.dart';
import '../providers/multi_server_provider.dart';
import '../providers/watch_state_store.dart';
@@ -882,19 +883,17 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final colorScheme = Theme.of(context).colorScheme;
final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface;
final foregroundColor = colorScheme.onSurface;
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
overlayColor.withValues(alpha: 0.7),
overlayColor.withValues(alpha: 0.5),
overlayColor.withValues(alpha: 0.3),
Colors.transparent,
],
stops: const [0.0, 0.3, 0.6, 1.0],
),
return RasterizedGradient(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
overlayColor.withValues(alpha: 0.7),
overlayColor.withValues(alpha: 0.5),
overlayColor.withValues(alpha: 0.3),
Colors.transparent,
],
stops: const [0.0, 0.3, 0.6, 1.0],
),
child: Padding(
padding: .only(top: statusBarHeight, left: 16, right: 16, bottom: 8),
+9 -10
View File
@@ -83,6 +83,7 @@ import '../widgets/focusable_tab_chip.dart';
import '../widgets/hub_section.dart';
import '../widgets/ios_status_bar_tap_scroll_to_top.dart';
import '../widgets/loading_indicator_box.dart';
import '../widgets/rasterized_gradient.dart';
import '../widgets/tv_browse_rail.dart';
import '../widgets/tv_spotlight_background.dart';
@@ -3309,9 +3310,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
child: child!,
),
),
child: Container(
child: SizedBox(
height: MediaQuery.paddingOf(context).top + 58,
decoration: BoxDecoration(
child: RasterizedGradient(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
@@ -4130,14 +4131,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
child: Builder(
builder: (context) {
final bgColor = Theme.of(context).scaffoldBackgroundColor;
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor],
stops: const [0.3, 0.8, 1.0],
),
return RasterizedGradient(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor],
stops: const [0.3, 0.8, 1.0],
),
);
},
+214
View File
@@ -0,0 +1,214 @@
import 'dart:async' show unawaited;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
/// Paints an axis-aligned [LinearGradient] as a pre-rasterized 1-D strip
/// texture stretched across the box instead of evaluating Skia's gradient
/// shader per pixel.
///
/// Skia's dithered gradient shaders cost ~10ms per full-screen pass on
/// low-end TV GPUs (measured on a Mali-G31 box: the two spotlight scrims
/// alone were ~20ms of a 27ms raster frame, while flat blended quads on the
/// same coverage were ~free). A strip texture blends at flat-quad cost: the
/// strip stays resident in the GPU texture cache, so per-pixel bandwidth is
/// framebuffer-only.
///
/// The strip quantizes the ramp to 8-bit without dithering, which is
/// indistinguishable for scrims with large alpha ranges (steps land every
/// few pixels). Don't use it for subtle low-contrast ramps spanning huge
/// areas — those are where undithered gradients band visibly.
///
/// Gradients that aren't pure-horizontal/vertical (or that carry a
/// transform or non-clamp tile mode) paint through the regular shader path
/// unchanged, as does the first frame while the strip decodes.
class RasterizedGradient extends StatefulWidget {
final LinearGradient gradient;
final Widget? child;
const RasterizedGradient({super.key, required this.gradient, this.child});
/// Bakes [gradient]'s strip ahead of time so the first build paints it
/// instead of the shader fallback. Tests use this to guarantee they
/// exercise the strip path.
@visibleForTesting
static Future<ui.Image?> prebake(LinearGradient gradient) => _GradientStripCache.bake(gradient);
@override
State<RasterizedGradient> createState() => _RasterizedGradientState();
}
class _RasterizedGradientState extends State<RasterizedGradient> {
ui.Image? _strip;
@override
void initState() {
super.initState();
_resolveStrip();
}
@override
void didUpdateWidget(RasterizedGradient oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.gradient != oldWidget.gradient) {
_strip = null;
_resolveStrip();
}
}
void _resolveStrip() {
final gradient = widget.gradient;
final cached = _GradientStripCache.get(gradient);
if (cached != null) {
_strip = cached;
return;
}
_GradientStripCache.bake(gradient).then((image) {
if (!mounted || image == null || widget.gradient != gradient) return;
setState(() => _strip = image);
});
}
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _GradientStripPainter(strip: _strip, gradient: widget.gradient),
child: widget.child,
);
}
}
class _GradientStripPainter extends CustomPainter {
final ui.Image? strip;
final LinearGradient gradient;
const _GradientStripPainter({required this.strip, required this.gradient});
@override
void paint(Canvas canvas, Size size) {
if (size.isEmpty) return;
final rect = Offset.zero & size;
final strip = this.strip;
if (strip == null) {
canvas.drawRect(rect, Paint()..shader = gradient.createShader(rect));
return;
}
canvas.drawImageRect(
strip,
Rect.fromLTWH(0, 0, strip.width.toDouble(), strip.height.toDouble()),
rect,
Paint()..filterQuality = FilterQuality.low,
);
}
@override
bool shouldRepaint(_GradientStripPainter oldDelegate) =>
strip != oldDelegate.strip || gradient != oldDelegate.gradient;
}
abstract final class _GradientStripCache {
static const _stripLength = 1024;
static const _maxEntries = 32;
// Strips are ~4KB each and may be referenced by in-flight frames, so the
// cache never disposes entries; the cap bounds it to ~128KB worst case.
static final _strips = <LinearGradient, ui.Image>{};
static final _pending = <LinearGradient, Future<ui.Image?>>{};
static ui.Image? get(LinearGradient gradient) => _strips[gradient];
static Future<ui.Image?> bake(LinearGradient gradient) {
final cached = _strips[gradient];
if (cached != null) return Future.value(cached);
return _pending[gradient] ??= _bake(gradient);
}
static Future<ui.Image?> _bake(LinearGradient gradient) async {
final bytes = _stripBytes(gradient);
if (bytes == null) return null; // Unsupported shape: shader fallback.
try {
final image = await _decode(bytes, _isVertical(gradient));
if (_strips.length >= _maxEntries) _strips.remove(_strips.keys.first);
_strips[gradient] = image;
return image;
} catch (_) {
return null; // Keep painting through the shader path.
} finally {
unawaited(_pending.remove(gradient));
}
}
static Future<ui.Image> _decode(Uint8List bytes, bool vertical) async {
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
final descriptor = ui.ImageDescriptor.raw(
buffer,
width: vertical ? 1 : _stripLength,
height: vertical ? _stripLength : 1,
pixelFormat: ui.PixelFormat.rgba8888,
);
final codec = await descriptor.instantiateCodec();
final frame = await codec.getNextFrame();
codec.dispose();
descriptor.dispose();
buffer.dispose();
return frame.image;
}
static bool _isVertical(LinearGradient gradient) {
final begin = gradient.begin as Alignment;
final end = gradient.end as Alignment;
return begin.x == end.x;
}
/// Premultiplied RGBA texels along the gradient axis ([ui.PixelFormat.rgba8888]
/// is consumed as premultiplied), or null when the gradient can't be
/// represented as an axis-aligned clamped strip.
static Uint8List? _stripBytes(LinearGradient gradient) {
if (gradient.transform != null || gradient.tileMode != TileMode.clamp) return null;
final begin = gradient.begin;
final end = gradient.end;
if (begin is! Alignment || end is! Alignment) return null;
final vertical = begin.x == end.x && begin.y != end.y;
final horizontal = begin.y == end.y && begin.x != end.x;
if (!vertical && !horizontal) return null;
// Box-relative fractions of the ramp's endpoints along the axis.
final b = vertical ? (begin.y + 1) / 2 : (begin.x + 1) / 2;
final e = vertical ? (end.y + 1) / 2 : (end.x + 1) / 2;
final colors = gradient.colors;
if (colors.isEmpty) return null;
final stops =
gradient.stops ?? [for (var i = 0; i < colors.length; i++) colors.length == 1 ? 0.0 : i / (colors.length - 1)];
if (stops.length != colors.length) return null;
final bytes = Uint8List(_stripLength * 4);
for (var i = 0; i < _stripLength; i++) {
final f = i / (_stripLength - 1);
final t = ((f - b) / (e - b)).clamp(0.0, 1.0);
final color = _colorAt(colors, stops, t);
// Skia gradients interpolate straight-alpha, then premultiply at
// shading — lerp first, premultiply last replicates that exactly.
final a = color.a;
bytes[i * 4] = (color.r * a * 255.0).round().clamp(0, 255);
bytes[i * 4 + 1] = (color.g * a * 255.0).round().clamp(0, 255);
bytes[i * 4 + 2] = (color.b * a * 255.0).round().clamp(0, 255);
bytes[i * 4 + 3] = (a * 255.0).round().clamp(0, 255);
}
return bytes;
}
static Color _colorAt(List<Color> colors, List<double> stops, double t) {
if (t <= stops.first) return colors.first;
if (t >= stops.last) return colors.last;
for (var i = 0; i < stops.length - 1; i++) {
if (t > stops[i + 1]) continue;
final span = stops[i + 1] - stops[i];
final local = span <= 0 ? 0.0 : (t - stops[i]) / span;
return Color.lerp(colors[i], colors[i + 1], local)!;
}
return colors.last;
}
}
+12 -15
View File
@@ -31,6 +31,7 @@ import 'horizontal_scroll_with_arrows.dart';
import 'listenable_selector.dart';
import 'media_card.dart';
import 'optimized_media_image.dart';
import 'rasterized_gradient.dart';
import 'settings_builder.dart';
class TvBrowseRailLayoutMetrics {
@@ -1505,14 +1506,12 @@ class TvBrowseRailState extends State<TvBrowseRail> {
imageType: ImageType.avatar,
fallbackIcon: Symbols.person_rounded,
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.78)],
stops: const [0.45, 1.0],
),
RasterizedGradient(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.78)],
stops: const [0.45, 1.0],
),
),
Positioned(
@@ -1769,13 +1768,11 @@ class _RailBackgroundBleed extends StatelessWidget {
tween: Tween(end: target),
duration: FocusTheme.getAnimationDuration(context),
curve: Curves.easeOutCubic,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, backgroundColor.withValues(alpha: 0.7)],
),
child: RasterizedGradient(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, backgroundColor.withValues(alpha: 0.7)],
),
),
builder: (context, bleedLeft, child) {
+13 -16
View File
@@ -19,6 +19,7 @@ import 'app_icon.dart';
import 'fitting_title_text.dart';
import 'media_rating_badge.dart';
import 'optimized_media_image.dart' show blurArtwork;
import 'rasterized_gradient.dart';
class TvSpotlightBackground extends StatelessWidget {
final MediaItem? item;
@@ -77,14 +78,12 @@ class TvSpotlightBackground extends StatelessWidget {
),
),
_buildHorizontalScrim(bgColor),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black.withValues(alpha: 0.45), Colors.transparent, bgColor.withValues(alpha: 0.96)],
stops: const [0.0, 0.38, 1.0],
),
RasterizedGradient(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black.withValues(alpha: 0.45), Colors.transparent, bgColor.withValues(alpha: 0.96)],
stops: const [0.0, 0.38, 1.0],
),
),
if (media != null && showInfo)
@@ -176,14 +175,12 @@ class TvSpotlightBackground extends StatelessWidget {
}
Widget _buildHorizontalScrim(Color bgColor) {
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [bgColor.withValues(alpha: 0.86), bgColor.withValues(alpha: 0.32), Colors.transparent],
stops: const [0.0, 0.56, 1.0],
),
return RasterizedGradient(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [bgColor.withValues(alpha: 0.86), bgColor.withValues(alpha: 0.32), Colors.transparent],
stops: const [0.0, 0.56, 1.0],
),
);
}
+20 -18
View File
@@ -76,6 +76,7 @@ import 'widgets/mobile_skip_zones.dart';
import 'widgets/skip_marker_button.dart';
import 'widgets/track_chapter_controls.dart';
import 'widgets/performance_overlay/performance_overlay.dart';
import '../rasterized_gradient.dart';
import 'mobile_video_controls.dart';
import 'desktop_video_controls.dart';
import 'package:provider/provider.dart';
@@ -804,24 +805,25 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
child: ValueListenableBuilder<bool>(
valueListenable: widget.hasFirstFrame ?? _fallbackHasFirstFrame,
builder: (context, hasFrame, child) {
return Container(
decoration: BoxDecoration(
// Use solid black when loading, gradient when loaded
color: hasFrame ? null : Colors.black,
gradient: hasFrame
? LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.7),
Colors.transparent,
Colors.transparent,
Colors.black.withValues(alpha: 0.7),
],
stops: const [0.0, 0.2, 0.8, 1.0],
)
: null,
),
// Solid black while loading, scrim once frames flow.
// Both states share one widget type: hasFrame flips
// on every in-place episode switch / live-TV zap, and
// a runtimeType change here would re-inflate the whole
// controls subtree and drop its state.
return RasterizedGradient(
gradient: hasFrame
? LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.7),
Colors.transparent,
Colors.transparent,
Colors.black.withValues(alpha: 0.7),
],
stops: const [0.0, 0.2, 0.8, 1.0],
)
: const LinearGradient(colors: [Colors.black, Colors.black]),
child: child,
);
},
@@ -0,0 +1,96 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/widgets/rasterized_gradient.dart';
/// The strip texture must composite identically to Skia's gradient shader.
/// Translucent non-black ramps are the regression case: rgba8888 strips are
/// consumed premultiplied, so straight-alpha baking renders them far too
/// bright (a white fade-to-transparent scrim becomes an opaque sheet).
void main() {
Future<ui.Image> capture(WidgetTester tester, Key key) async {
final boundary = tester.renderObject<RenderRepaintBoundary>(find.byKey(key));
late ui.Image image;
await tester.runAsync(() async => image = await boundary.toImage());
return image;
}
Future<List<int>> centerColumn(WidgetTester tester, ui.Image image) async {
late ByteData bytes;
await tester.runAsync(() async => bytes = (await image.toByteData())!);
final x = image.width ~/ 2;
final column = <int>[];
for (var y = 0; y < image.height; y += 8) {
final offset = (y * image.width + x) * 4;
column.addAll([bytes.getUint8(offset), bytes.getUint8(offset + 1), bytes.getUint8(offset + 2)]);
}
return column;
}
testWidgets('strip path matches the gradient shader over a colored background', (tester) async {
final gradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white.withValues(alpha: 0.8),
Colors.white.withValues(alpha: 0.4),
Colors.white.withValues(alpha: 0.0),
],
stops: const [0.0, 0.4, 1.0],
);
// Bake ahead of the build so RasterizedGradient paints the strip from
// its first frame instead of the (trivially identical) shader fallback.
late ui.Image? strip;
await tester.runAsync(() async => strip = await RasterizedGradient.prebake(gradient));
expect(strip, isNotNull, reason: 'axis-aligned gradient must bake to a strip');
const rasterizedKey = Key('rasterized');
const shaderKey = Key('shader');
Widget sample(Key key, Widget gradientBox) => RepaintBoundary(
key: key,
child: SizedBox(
width: 64,
height: 256,
child: Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Color(0xFF406080)),
gradientBox,
],
),
),
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
sample(rasterizedKey, RasterizedGradient(gradient: gradient)),
sample(shaderKey, DecoratedBox(decoration: BoxDecoration(gradient: gradient))),
],
),
),
),
);
final rasterized = await centerColumn(tester, await capture(tester, rasterizedKey));
final shader = await centerColumn(tester, await capture(tester, shaderKey));
for (var i = 0; i < shader.length; i++) {
// Skia dithers its gradient shader; the strip doesn't. Allow a couple
// of levels of noise — the premul bug is off by 50+ on this ramp.
expect(
(rasterized[i] - shader[i]).abs(),
lessThanOrEqualTo(3),
reason: 'channel ${i % 3} at sample ${i ~/ 3}: strip=${rasterized[i]} shader=${shader[i]}',
);
}
});
}