Files
plezy/test/widgets/rasterized_gradient_test.dart
edde746 b08b3b846a 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.
2026-07-02 11:41:25 +02:00

97 lines
3.4 KiB
Dart

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]}',
);
}
});
}