Fetching latest headlines…
Crisp pixel art in Flutter: integer scales, masks and widgets

Dev

Crisp pixel art in Flutter: integer scales, masks and widgets

Dev.toUnited States · NORTH AMERICA

I'm a solo developer building Taskoria, a habit tracker in Flutter that looks and plays like a pixel-art RPG. Finishing a task deals damage to a monster. There are seven lands with six monsters and a...

1 views0 likes0 comments

I'm a solo developer building Taskoria, a habit tracker in Flutter that looks and plays like a pixel-art RPG. Finishing a task deals damage to a monster. There are seven lands with six monsters and a boss each, and you craft gear from what the monsters drop. Missing a day doesn't cost you HP, levels or gear; only the streak bonus resets. The app is at taskoria.app, and I've worked on it full time since the start of October.

This post isn't about the game design. It's about something more boring that took me a lot longer than I expected: getting 64 px sprites onto a phone screen without blurring them. Flutter gives you everything you need, but there are a few traps, and I walked into most of them.

Taskoria's battle screen: 64 px pixel-art monsters drawn at whole-number scales

1. FilterQuality.none, but not everywhere

The first fix everyone finds is filterQuality: FilterQuality.none. Flutter's default sampling smooths an image when it scales it. That's correct for photos and wrong for a 64x64 goblin, which ends up looking like it was drawn in wet paint.

The same 64x64 boss sprite enlarged with default smoothing (blurry) and with FilterQuality.none (crisp)

Image.asset(
  'assets/enemies/goblin.png', // 64x64 source
  width: 192,
  height: 192,
  filterQuality: FilterQuality.none, // nearest-neighbour
);

The same applies to DecorationImage, which has its own filterQuality. My location backgrounds are 256 px images stretched across the whole arena with BoxFit.cover, and they need it too.

The trap is applying it everywhere out of habit. Nearest-neighbour is for upscaling art that was drawn on a small grid. My coin icon is a 618x618 PNG drawn at 18 logical px. That's a big downscale, and there FilterQuality.none throws away whole rows of pixels and the coin shimmers. So the coin keeps FilterQuality.high. The real rule is about source size against display size, not "pixel-art app, so everything gets none".

2. Compute the multiple, never the size

Nearest-neighbour alone isn't enough. Draw a 64 px sprite at 1.5x or 2.5x and some source pixels come out 2 px wide while their neighbours come out 3 px wide. The image is sharp, but it reads as wobbly, almost blurry, and on the battle screen that lands exactly on the art the screen exists to show off.

Most of my UI is sized with flutter_screenutil (56.r, 16.r, ...), which scales by the screen width. That's fine for padding, but it makes every sprite's scale a fraction that depends on the device. When I redesigned the battle screen to show the fighters large, the arena stopped deriving the multiple from the size and started deriving the size from the multiple:

/// Whole-number scale for the 64-unit sprite stage.
/// 3x normally, 4x for bosses, one step less when the arena is short.
int spriteScale({required double arenaHeight, required bool isBoss}) {
  final base = arenaHeight < 380 ? 2 : 3;
  return isBoss ? base + 1 : base;
}

// in build(), inside a LayoutBuilder:
final enemySize = 64.0 * spriteScale(
  arenaHeight: constraints.maxHeight,
  isBoss: enemyIsBoss,
);

The plan for that screen states it as a rule: size = 64 × scale, never .r, never a fraction. There's one caveat I should be honest about. This is an integer in logical pixels. On a device with a fractional devicePixelRatio (plenty of Android phones), the physical result still isn't a perfect grid. On the usual 2x and 3x screens it is.

3. Compose at native size, scale once

The hero isn't one image. It's a stack: body, armour, a separate head layer, helmet, weapon, each a PNG placed with hand-tuned offsets in "stage pixels". If every layer scaled itself, rounding would put a helmet one physical pixel off on some screen sizes.

So HeroInEquipment lays everything out on a fixed 64x64 stage and scales the finished composition once:

@override
Widget build(BuildContext context) {
  return FittedBox(
    fit: BoxFit.contain,
    child: SizedBox(
      width: 64,
      height: 64,
      child: Stack(
        alignment: Alignment.center,
        children: [
          heroBody,       // FilterQuality.none
          ...armour,      // Positioned by per-item offsets
          ...helmet,
          ...weapon,      // always on top: it's held in front of the body
        ],
      ),
    ),
  );
}

The same widget shows up in the inventory cell, the profile and the arena. The parent decides how big the box is, and the offsets inside stay proportional.

Hero, armour, head and helmet layers placed on one 64x64 stage, then scaled once as a single composition

4. Erasing hair under a helmet

Here's a problem I didn't see coming: hair pokes out through helmets. Each helmet ships with a mask PNG (helmet_2_mask.png) that is opaque wherever the hero's hair or head should disappear. I apply it with a ShaderMask using BlendMode.dstOut, which cuts the mask's shape out of the child:

Without the mask, hair pokes through the hood; the mask (pink) marks what to erase; with the mask applied to the hero layers only, the hair is gone and the armour is intact

Widget eraseHair(Widget layer, ui.Image? mask, double shiftX) {
  if (mask == null) return layer;
  final scale = 64 / mask.width; // masks are stored at 2x the stage
  final matrix = Matrix4.identity()
    ..translateByDouble(shiftX, 0, 0, 1)
    ..scaleByDouble(scale, scale, 1, 1);
  return ShaderMask(
    blendMode: BlendMode.dstOut,
    shaderCallback: (_) => ImageShader(
      mask, TileMode.decal, TileMode.decal, matrix.storage,
      filterQuality: FilterQuality.none, // keep the mask edge hard too
    ),
    child: layer,
  );
}

Two details matter. First, ImageShader has its own filterQuality. Leave it at the default and the mask edge gets a soft fringe of half-erased hair pixels. Second, and this one got me: the mask belongs to the layer whose pixels it describes. My first version wrapped the whole stack in one ShaderMask. It looked right in my fit-check tool, which renders one item at a time. Once someone wore armour and a helmet, the mask sliced the shoulder spikes off the armour too. When I counted afterwards, 2,235 of 2,418 armour × helmet × hero combinations were affected. Now the mask wraps only the hero body and the hero head layer, and the equipment keeps all of its pixels.

5. Home-screen widgets: pre-scale in Dart

Home-screen widgets are native (SwiftUI on iOS), so none of the Flutter rendering above applies there. I use the home_widget package's renderFlutterWidget to render the current enemy to a PNG, and I pick the pixel ratio as an integer so the PNG is already a clean multiple of the sprite:

const spriteTargetPx = 256;

final scale = max(1, spriteTargetPx ~/ max(image.width, image.height));
await HomeWidget.renderFlutterWidget(
  RawImage(image: image, width: w, height: h,
      filterQuality: ui.FilterQuality.none),
  key: WidgetKeys.enemyPng,
  logicalSize: Size(w, h),
  pixelRatio: scale.toDouble(), // 64 px sprite -> 256 px PNG, exactly 4x
);

On the Swift side every sprite Image uses .interpolation(.none). WidgetKit has a memory budget for images, so the loader shrinks anything over 384 px by a whole-number factor, again with interpolation off. One more snapshot-specific trap: renderFlutterWidget lays out and paints exactly one frame. Any layer still decoding at that moment is simply missing from the PNG. Before the hero snapshot, I await every layer and the helmet mask first instead of just pre-warming them.

Comments (0)

Sign in to join the discussion

Be the first to comment!