SpriteBatch draws item colors differently when not using drawAtlas (useAtlas: false)
Maintainer thường phản hồi trong vòng 1 ngày
Đánh giá
Issue này chưa được đánh giá.
Mô tả
What happened?
SpriteBatch can draw in two ways:
- With
Canvas.drawAtlas(the default). - Without it, by drawing every sprite with
drawImageRect. We call this the "fallback" below. It is used whenuseAtlasisfalse.
When a batch item has a color, the fallback draws it very differently from drawAtlas:
- It ignores
defaultBlendMode. The color is always drawn as a box behind the sprite. - It uses the blend mode from
render(blendMode: ...)in the wrong place. IndrawAtlas, the blend mode mixes the color with the sprite. In the fallback, it mixes the sprite with everything that is already on the canvas. For modes likesrcOut,srcInandmodulate, this cuts a transparent hole into whatever was drawn before (the background, other components, ...). - It also writes that blend mode into the
Paintthat was passed torender, so the change stays on that paint after the call.
Only BlendMode.srcOver looks the same in both paths, and that is the only case that the current golden test checks.
Here is the output of the example below on main:
How to read the image:
- Every cell draws the same sprite: a red circle on a transparent background. The cell has a green background, and the batch item has a half transparent blue color (
0x800000FF). - Rows use different blend modes, from top to bottom:
srcOver,srcOut,srcInandmodulate. - Column 1 uses
drawAtlas(useAtlas: true). This is the expected result. - Column 2 uses the fallback (
useAtlas: false) with the blend mode passed torender(blendMode: ...). - Column 3 uses the fallback (
useAtlas: false) with the blend mode set asdefaultBlendMode. - The checkerboard areas are fully transparent: the fallback erased the green background there. (The checkerboard is not part of the rendered image. It was added behind it afterwards, to make the transparent areas easy to see.)
The first row looks the same in all three columns. In the other rows, column 2 has holes in the background and column 3 always shows the blue box, no matter which blend mode is used.
What do you expect?
The fallback should look the same as drawAtlas, for every blend mode:
- The blend mode (
render(blendMode: ...)ordefaultBlendMode) should only mix the item color with its sprite, likedrawAtlasdoes. - The result should then be drawn on the canvas normally, without changing what was drawn before.
- The
Paintpassed torendershould not be changed.
How can we reproduce this?
- Copy the test from the example below into
packages/flame/test/sprite_batch_fallback_test.dart. - Run
flutter test test/sprite_batch_fallback_test.dartinsidepackages/flame. - The tests for
srcOut,srcInandmodulatefail, because the fallback renders different pixels thandrawAtlas. The test also saves the image above assprite_batch_fallback.png.
What steps should take to fix this?
In the non-atlas branch of SpriteBatch.render:
- For items with a color, use the same blend mode as the atlas path (
blendMode ?? defaultBlendMode), and use it only to mix the color with the sprite. One way is to draw the color and the sprite inside their owncanvas.saveLayer, and then draw that layer on the canvas with the normal paint. - Do not write the blend mode into the
Paintthat was passed torender. Use a separate internal paint instead. - Add a test that renders the same batch with and without the atlas for a few blend modes and compares the pixels.
Note for whoever fixes this: BatchItem.matrix has 0 as its z scale. Normal drawing works with it, but a saveLayer under this matrix renders nothing. Changing that value to 1 fixes it, and it does not change how the sprites are drawn.
Do have an example of where the bug occurs?
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui';
import 'package:flame/extensions.dart';
import 'package:flame/sprite.dart';
import 'package:flutter_test/flutter_test.dart';
const cellSize = 100.0;
const background = Color(0xFF9AD0A0); // green
const itemColor = Color(0x800000FF); // half transparent blue
const modes = [
BlendMode.srcOver,
BlendMode.srcOut,
BlendMode.srcIn,
BlendMode.modulate,
];
/// A red circle on a transparent background.
Future<Image> createSprite() {
final recorder = PictureRecorder();
Canvas(recorder).drawCircle(
const Offset(cellSize / 2, cellSize / 2),
cellSize / 3,
Paint()..color = const Color(0xFFE53935),
);
return recorder.endRecording().toImage(cellSize.toInt(), cellSize.toInt());
}
/// Draws the sprite with [itemColor] on top of the green background.
void drawItem(
Canvas canvas,
Image sprite, {
required bool useAtlas,
required BlendMode mode,
required bool useDefaultBlendMode,
Offset offset = Offset.zero,
}) {
final batch = SpriteBatch(
sprite,
useAtlas: useAtlas,
defaultBlendMode: useDefaultBlendMode ? mode : null,
);
batch.add(
source: const Rect.fromLTWH(0, 0, cellSize, cellSize),
offset: Vector2(offset.dx, offset.dy),
color: itemColor,
);
batch.render(canvas, blendMode: useDefaultBlendMode ? null : mode);
}
Future<Uint8List> renderItem(
Image sprite, {
required bool useAtlas,
required BlendMode mode,
required bool useDefaultBlendMode,
}) async {
final recorder = PictureRecorder();
final canvas = Canvas(recorder)
..drawPaint(Paint()..color = background);
drawItem(
canvas,
sprite,
useAtlas: useAtlas,
mode: mode,
useDefaultBlendMode: useDefaultBlendMode,
);
final image = await recorder
.endRecording()
.toImage(cellSize.toInt(), cellSize.toInt());
return (await image.toByteData())!.buffer.asUint8List();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
for (final useDefaultBlendMode in [false, true]) {
final how = useDefaultBlendMode
? 'defaultBlendMode'
: 'render(blendMode: ...)';
for (final mode in modes) {
test('${mode.name} with $how looks the same without the atlas', () async {
final sprite = await createSprite();
final atlas = await renderItem(
sprite,
useAtlas: true,
mode: mode,
useDefaultBlendMode: useDefaultBlendMode,
);
final fallback = await renderItem(
sprite,
useAtlas: false,
mode: mode,
useDefaultBlendMode: useDefaultBlendMode,
);
// Allow small rounding differences.
var differentPixels = 0;
for (var i = 0; i < atlas.length; i += 4) {
for (var c = 0; c < 4; c++) {
if ((atlas[i + c] - fallback[i + c]).abs() > 2) {
differentPixels++;
break;
}
}
}
expect(differentPixels, 0, reason: 'pixels that are different');
});
}
}
test('save a comparison image', () async {
final sprite = await createSprite();
final recorder = PictureRecorder();
final canvas = Canvas(recorder);
for (final (row, mode) in modes.indexed) {
final columns = [
(useAtlas: true, useDefaultBlendMode: false),
(useAtlas: false, useDefaultBlendMode: false),
(useAtlas: false, useDefaultBlendMode: true),
];
for (final (column, options) in columns.indexed) {
final offset = Offset(column * cellSize, row * cellSize);
canvas
..save()
..clipRect(offset & const Size(cellSize, cellSize))
..drawRect(
offset & const Size(cellSize, cellSize),
Paint()..color = background,
);
drawItem(
canvas,
sprite,
useAtlas: options.useAtlas,
mode: mode,
useDefaultBlendMode: options.useDefaultBlendMode,
offset: offset,
);
canvas.restore();
}
}
final image = await recorder.endRecording().toImage(
(cellSize * 3).toInt(),
(cellSize * modes.length).toInt(),
);
final png = await image.toByteData(format: ImageByteFormat.png);
File('sprite_batch_fallback.png').writeAsBytesSync(
png!.buffer.asUint8List(),
);
});
}
Relevant log output
00:00 +1 -1: srcOut with render(blendMode: ...) looks the same without the atlas [E]
Expected: <0>
Actual: <10000>
00:00 +1 -2: srcIn with render(blendMode: ...) looks the same without the atlas [E]
Expected: <0>
Actual: <10000>
00:00 +1 -3: modulate with render(blendMode: ...) looks the same without the atlas [E]
Expected: <0>
Actual: <10000>
00:00 +2 -4: srcOut with defaultBlendMode looks the same without the atlas [E]
Expected: <0>
Actual: <10000>
00:00 +2 -5: srcIn with defaultBlendMode looks the same without the atlas [E]
Expected: <0>
Actual: <10000>
00:00 +2 -6: modulate with defaultBlendMode looks the same without the atlas [E]
Expected: <0>
Actual: <10000>
00:00 +3 -6: Some tests failed.
Execute in a terminal and put output into the code block below
Output of: flutter doctor -v
[√] Flutter (Channel stable, 3.47.4, on Microsoft Windows [Version 10.0.22631.6199], locale en-US)
• Flutter version 3.47.4 on channel stable
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 9584c6713b (6 weeks ago), 2026-09-10 15:25:10 -0700
• Engine revision 06a2e2a110
• Dart version 3.13.3
• DevTools version 2.60.0
[√] Windows Version (11 Pro 64-bit, 23H2, 2009)
[√] Android toolchain - develop for Android devices (Android SDK version 36.1.0)
[√] Chrome - develop for the web
[√] Visual Studio - develop Windows apps (Visual Studio Community 2026 18.10.3)
[√] Connected device (3 available)
[√] Network resources
• No issues found!
Affected platforms
All
Other information
- The fallback is not only used with
useAtlas: false. When a batch has flipped items,SpriteBatchalso uses the fallback for the first frames, while the flipped atlas is being generated. So colored items can look wrong for a moment even withuseAtlas: true. - Tested on
main(f9560edcf) withflutter teston Windows. The fallback code does not depend on the platform, so it should happen everywhere. - Found while adding per-tile opacity to
TileStackinflame_tiled(#2244), which uses item colors to fade single tiles.
Are you interested in working on a PR for this?
- I want to work on this
- Ngôn ngữ chính
- Dart
- Star
- 10.8k
- Fork
- 1.1k
- Merge trung bình
- 1 ngày 15 giờ
- Pull request đã merge (30 ngày)
- 50
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của flame-engine/flame
-
flame_svg: Add support to load SVG files as Path objects for collision purposesCó thể đã có người làm @adario đã nhận 3 ngày trước. Đang mởenhancement
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 15/100
flame-engine/flame#4120 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
enhancement v2
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 25/100
flame-engine/flame#4116 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 35/100
flame-engine/flame#4063 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
TextComponent shadows are not affected by OpacityEffectCó thể đã có người làm @spydon đã nhận 43 ngày trước. Đang mở
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 72/100
flame-engine/flame#4013 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
enhancement
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 35/100
flame-engine/flame#4012 · 4 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của flame-engine/flame
Issue tương tự
-
Standalone dash flashes as its own RSVP step (e.g. Russian dialogue)Có thể đã có người làm @bezig01 đã nhận hôm nay. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
daniel-pimenta-cc/ledor#53 ·
-
Iteration CounterĐang mởenhancement firebase
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 65/100
brysonddelozier/scribbles#24 ·
-
bug good first issue P2-Medium
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 64/100
basictech01/uniun#305 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 70/100
daohoangson/flutter_widget_from_html#1657 ·
Maintainer thường phản hồi trong vòng 3 ngày
-
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 88/100
RevenueCat/purchases-flutter#1973 ·
Maintainer thường phản hồi trong vòng 1 ngày