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:
@@ -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),
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]}',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user