Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

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

クローズ
#4,122 コメント 0 件 リアクション 0 件 担当者 1 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

@ufrshubham がすでに取り組んでいます。

2026年10月19日 から。

  • #4123 @ufrshubham による — マージ済み

評価

この issue はまだ評価されていません。

説明

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
主要言語
Dart
スター
10.8k
フォーク
1.1k
平均マージ
1日 16時間
マージ済み PR(30日)
60

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

flame-engine/flame のほかの issue

flame-engine/flame の issue をすべて見る

似ている issue

Dart の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。