Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

SpriteBatch draws item colors differently when not using drawAtlas (useAtlas: false)

Đang mở
#4,122 0 bình luận 0 reaction 1 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

@ufrshubham đang làm issue này rồi.

Từ ngày 19/10/2026.

  • #4123 của @ufrshubham — đang mở

Đánh giá

Issue này chưa được đánh giá.

Mô tả

bug
What happened?

SpriteBatch can draw in two ways:

  1. With Canvas.drawAtlas (the default).
  2. Without it, by drawing every sprite with drawImageRect. We call this the "fallback" below. It is used when useAtlas is false.

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. In drawAtlas, 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 like srcOut, srcIn and modulate, this cuts a transparent hole into whatever was drawn before (the background, other components, ...).
  • It also writes that blend mode into the Paint that was passed to render, 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:

Image

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, srcIn and modulate.
  • Column 1 uses drawAtlas (useAtlas: true). This is the expected result.
  • Column 2 uses the fallback (useAtlas: false) with the blend mode passed to render(blendMode: ...).
  • Column 3 uses the fallback (useAtlas: false) with the blend mode set as defaultBlendMode.
  • 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: ...) or defaultBlendMode) should only mix the item color with its sprite, like drawAtlas does.
  • The result should then be drawn on the canvas normally, without changing what was drawn before.
  • The Paint passed to render should not be changed.
How can we reproduce this?
  1. Copy the test from the example below into packages/flame/test/sprite_batch_fallback_test.dart.
  2. Run flutter test test/sprite_batch_fallback_test.dart inside packages/flame.
  3. The tests for srcOut, srcIn and modulate fail, because the fallback renders different pixels than drawAtlas. The test also saves the image above as sprite_batch_fallback.png.
What steps should take to fix this?

In the non-atlas branch of SpriteBatch.render:

  1. 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 own canvas.saveLayer, and then draw that layer on the canvas with the normal paint.
  2. Do not write the blend mode into the Paint that was passed to render. Use a separate internal paint instead.
  3. 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, SpriteBatch also uses the fallback for the first frames, while the flipped atlas is being generated. So colored items can look wrong for a moment even with useAtlas: true.
  • Tested on main (f9560edcf) with flutter test on Windows. The fallback code does not depend on the platform, so it should happen everywhere.
  • Found while adding per-tile opacity to TileStack in flame_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

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. 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.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của flame-engine/flame

Tất cả issue của flame-engine/flame

Issue tương tự

Thêm issue về Dart

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.