diff --git a/doc/flame/rendering/post_processing.md b/doc/flame/rendering/post_processing.md index 8964f8c70cc..67cfe446ace 100644 --- a/doc/flame/rendering/post_processing.md +++ b/doc/flame/rendering/post_processing.md @@ -124,7 +124,9 @@ In this example: - A fragment shader (`pixelation.frag`) is loaded and used to apply a pixelation effect. - The `rasterizeSubtree` method captures the component tree rendering as a texture, which the - shader uses to generate the pixelated output. + shader uses to generate the pixelated output. The texture is rendered at the post process's + `pixelRatio` and covers the whole subtree, so the shader can sample it with coordinates from 0 + to 1 across `size`. - The effect dynamically changes over time, creating an animated pixelation effect. diff --git a/examples/games/crystal_ball/lib/src/game/post_processes/water.dart b/examples/games/crystal_ball/lib/src/game/post_processes/water.dart index 97046e113ea..5e1d3d159d8 100644 --- a/examples/games/crystal_ball/lib/src/game/post_processes/water.dart +++ b/examples/games/crystal_ball/lib/src/game/post_processes/water.dart @@ -33,7 +33,6 @@ class WaterPostProcess({ ..setFloatUniforms((value) { value ..setVector(size) - ..setFloat(pixelRatio) ..setFloat(uvGround) ..setFloat(time); }) diff --git a/examples/games/crystal_ball/shaders/ground.frag b/examples/games/crystal_ball/shaders/ground.frag index 91d7879b940..4fe44944ef9 100644 --- a/examples/games/crystal_ball/shaders/ground.frag +++ b/examples/games/crystal_ball/shaders/ground.frag @@ -5,7 +5,6 @@ precision highp float; #include uniform vec2 uSize; -uniform float uPixelSize; uniform float uWaterLevel; uniform float uTime; @@ -45,7 +44,7 @@ vec4 fragment(vec2 uv) { fogIntensity = waterProximity * 0.1; } - vec4 baseColor = texture(tGameCanvas, reflectedUv / uPixelSize) * waterColor; + vec4 baseColor = texture(tGameCanvas, reflectedUv) * waterColor; return mix(baseColor, fogColor, fogIntensity); } diff --git a/packages/flame/lib/src/post_process/post_process.dart b/packages/flame/lib/src/post_process/post_process.dart index a9cc45a5966..1b8e7875661 100644 --- a/packages/flame/lib/src/post_process/post_process.dart +++ b/packages/flame/lib/src/post_process/post_process.dart @@ -52,8 +52,9 @@ import 'package:flutter/foundation.dart'; /// - [PostProcessingContextFinder] for a way to access the post process /// context from within a component during rendering abstract class PostProcess({double? pixelRatio}) { - /// The pixel ratio of the screen. This is used to scale the image generated - /// by [rasterizeSubtree] to the correct size. + /// The pixel ratio of the screen. The image generated by [rasterizeSubtree] + /// is rendered at this many pixels per logical pixel, so that it stays sharp + /// on high density screens. /// /// Defaults to [FlutterView.devicePixelRatio]. double pixelRatio = @@ -111,13 +112,17 @@ abstract class PostProcess({double? pixelRatio}) { /// subtree to an image, which can be used as a texture in a shader. This /// is an expensive operation, so it should be used sparingly. /// + /// The image is `pixelRatio` times the size of the post process and the + /// subtree fills all of it, so a shader can sample it with texture + /// coordinates going from 0 to 1 across the size of the post process. + /// /// This method will set the context of the post process, so that /// components can know they are being rendered within a post process. @nonVirtual @protected ui.Image rasterizeSubtree() { final recorder = PictureRecorder(); - final innerCanvas = Canvas(recorder); + final innerCanvas = Canvas(recorder)..scale(pixelRatio); renderSubtree(innerCanvas); diff --git a/packages/flame/test/_goldens/post_process_rasterize_subtree.png b/packages/flame/test/_goldens/post_process_rasterize_subtree.png new file mode 100644 index 00000000000..fe531e17dde Binary files /dev/null and b/packages/flame/test/_goldens/post_process_rasterize_subtree.png differ diff --git a/packages/flame/test/post_process/rasterize_subtree_test.dart b/packages/flame/test/post_process/rasterize_subtree_test.dart new file mode 100644 index 00000000000..6d078ca3431 --- /dev/null +++ b/packages/flame/test/post_process/rasterize_subtree_test.dart @@ -0,0 +1,151 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame/post_process.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('rasterizeSubtree', () { + const red = Color(0xFFFF0000); + const blue = Color(0xFF0000FF); + + for (final pixelRatio in [1.0, 1.5, 2.0, 3.0]) { + test('fills the whole image at pixel ratio $pixelRatio', () async { + final size = Vector2(40, 20); + final postProcess = _CapturingPostProcess(pixelRatio: pixelRatio); + + postProcess.render( + Canvas(PictureRecorder()), + size, + (canvas) { + // Red on the left half, blue on the right half. + canvas.drawRect( + Rect.fromLTWH(0, 0, size.x / 2, size.y), + Paint()..color = red, + ); + canvas.drawRect( + Rect.fromLTWH(size.x / 2, 0, size.x / 2, size.y), + Paint()..color = blue, + ); + }, + (_) {}, + ); + + final image = postProcess.image!; + expect(image.width, (size.x * pixelRatio).ceil()); + expect(image.height, (size.y * pixelRatio).ceil()); + + final pixels = await _Pixels.of(image); + expect(pixels.at(0, 0), red); + expect(pixels.at(image.width ~/ 4, image.height ~/ 2), red); + expect(pixels.at(image.width * 3 ~/ 4, image.height ~/ 2), blue); + expect(pixels.at(image.width - 1, image.height - 1), blue); + }); + } + }); + + group('PostProcessComponent with rasterizeSubtree', () { + const goldenFile = '../_goldens/post_process_rasterize_subtree.png'; + final goldenSize = Vector2(200, 100); + final cardPosition = Vector2(-30, -20); + + testGolden( + 'renders the subtree without a post process', + (game, tester) async { + game.world.add(_TestCard()..position = cardPosition); + }, + goldenFile: goldenFile, + size: goldenSize, + ); + + for (final pixelRatio in [1.0, 2.0, 3.0]) { + testGolden( + 'renders the rasterized subtree at its full size at pixel ratio ' + '$pixelRatio', + (game, tester) async { + game.world.add( + PostProcessComponent( + postProcess: _DrawImagePostProcess(pixelRatio: pixelRatio), + position: cardPosition, + size: _TestCard.cardSize, + children: [_TestCard()], + ), + ); + }, + goldenFile: goldenFile, + size: goldenSize, + ); + } + }); +} + +class _CapturingPostProcess({super.pixelRatio}) extends PostProcess { + Image? image; + + @override + void postProcess(Vector2 size, Canvas canvas) { + image = rasterizeSubtree(); + } +} + +/// Draws the rasterized subtree stretched over the size of the post process, +/// which should look exactly like rendering the subtree directly. +class _DrawImagePostProcess({super.pixelRatio}) extends PostProcess { + final _paint = Paint()..filterQuality = FilterQuality.none; + + @override + void postProcess(Vector2 size, Canvas canvas) { + final image = rasterizeSubtree(); + canvas.drawImageRect( + image, + Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), + Offset.zero & size.toSize(), + _paint, + ); + } +} + +/// A card made only of pixel aligned rectangles, so that downscaling a +/// rasterized copy of it with nearest sampling gives back the same pixels. +class _TestCard() extends PositionComponent { + this : super(size: cardSize); + + static Vector2 get cardSize => Vector2(60, 40); + + static final _fill = Paint()..color = const Color(0xFF1E4D8C); + static final _border = Paint()..color = const Color(0xFFE53935); + static final _center = Paint()..color = const Color(0xFFFDD835); + static final _corner = Paint()..color = const Color(0xFF43A047); + + @override + void render(Canvas canvas) { + canvas + ..drawRect(const Rect.fromLTWH(0, 0, 60, 40), _fill) + ..drawRect(const Rect.fromLTWH(0, 0, 60, 4), _border) + ..drawRect(const Rect.fromLTWH(0, 36, 60, 4), _border) + ..drawRect(const Rect.fromLTWH(0, 0, 4, 40), _border) + ..drawRect(const Rect.fromLTWH(56, 0, 4, 40), _border) + ..drawRect(const Rect.fromLTWH(20, 14, 20, 12), _center) + ..drawRect(const Rect.fromLTWH(0, 0, 10, 10), _corner) + ..drawRect(const Rect.fromLTWH(50, 30, 10, 10), _corner); + } +} + +class _Pixels(final ByteData _bytes, final int _width) { + static Future<_Pixels> of(Image image) async { + final data = await image.toByteData(); + return _Pixels(data!, image.width); + } + + Color at(int x, int y) { + final offset = (y * _width + x) * 4; + return Color.fromARGB( + _bytes.getUint8(offset + 3), + _bytes.getUint8(offset), + _bytes.getUint8(offset + 1), + _bytes.getUint8(offset + 2), + ); + } +}