Skip to content

Working in two dimensions

2D in Orblit shares the engine rather than sitting beside it. Sprites are drawn by the same Filament renderer as every 3D scene, framed by the same camera and covered by the same interface layer. Two packages split the work: orblit_filament draws sprites, and orblit_sprite knows about atlases, animation, parallax and tile maps without drawing anything itself.

A scene’s sprites field takes layers. Each OrblitSprites layer is one image, one vertex buffer and one draw, however many sprites it holds. The Sprites example in the gallery puts twenty thousand coins in a single layer.

final layer = OrblitSprites(
key: 1,
sprites: OrblitSprites.pack(const [
OrblitSprite(x: 0, y: 0, width: 2, height: 2),
OrblitSprite(x: 3, y: 0, width: -2, height: 2), // a negative width flips it
]),
image: const OrblitTexture('/path/to/sheet.png'),
filter: OrblitFilter.sharp,
);
return OrblitScene(
objects: const [],
sprites: [layer],
camera: OrblitCamera(
position: Vector3(0, 0, 20),
target: Vector3.zero(),
orthographic: true,
viewHeight: 18,
),
post: OrblitPostProcess(
antiAliasing: AntiAliasing.off,
dithering: false,
grading: OrblitGrading(toneMapping: ToneMapping.linear),
),
);

Use an orthographic camera and ToneMapping.linear, or the renderer will grade the artist’s colours as if they were a photograph. A layer with no image draws each sprite as a flat coloured rectangle, which is handy before the art exists.

A few things are cheap and a few aren’t:

  • The sprites are only sent when revision changes. Bump it when you’ve moved something. Leave it alone and the layer’s buffer stays where it is.
  • transform and tint cost nothing to change. A scrolling backdrop is a layer sent once and moved with Matrix4.translationValues, not a thousand tiles re-sent every frame.
  • Layers draw by order, lowest first. Inside a layer, sprites draw in the order you gave them.
  • filter: OrblitFilter.sharp turns snap on unless you say otherwise, so pixel art lands on whole pixels and doesn’t shimmer as it moves.
  • blend: OrblitSpriteBlend.add only brightens, for sparks and glows.

Sprites are unlit and need nothing beyond OpenGL ES 3.0. They’ve been seen drawing on macOS and in Chrome, through WebGL 2. See platform support for the rest.

A sprite shows a rectangle of its layer’s image through u0, v0, u1 and v1, from 0 to 1. An atlas region gives you those numbers:

final atlas = Atlas.grid(
image: 'sheet.png',
imageWidth: 64,
imageHeight: 64,
cellWidth: 16,
cellHeight: 16,
name: 'frame', // regions are frame_0, frame_1 and so on
);
final uv = atlas['frame_4']!.uv(64, 64);
return OrblitSprite(x: 0, y: 0, u0: uv.u0, v0: uv.v0, u1: uv.u1, v1: uv.v1);

A hundred sprites in a hundred files is a hundred layers, so a hundred draws. The same hundred in one image is one. That, rather than disk space, is the reason to pack them.

// The commonest kind of sheet in practice: no metadata, just a grid.
final atlas = Atlas.grid(
image: 'hero.png',
imageWidth: 512,
imageHeight: 128,
cellWidth: 64,
cellHeight: 64,
name: 'run',
);
return atlas['run_0'];

The other kind comes from a packer:

import 'dart:io';
import 'package:orblit_sprite/orblit_sprite.dart';
// Reads what TexturePacker, Aseprite and the rest write, in both shapes of
// the format, because which one you get depends on a checkbox in the tool.
Future<List<Region>> runFrames(String path) async {
final packed = Atlas.read(await File(path).readAsString());
return packed?.sequence('run') ?? const [];
}

A file that isn’t one of these gives you null rather than an exception. An atlas is an asset somebody typed a path to, and refusing to load the whole level because one of them is malformed is worse than drawing it without.

sequence(prefix) returns every region whose name starts with a prefix, in order, which is how a sheet becomes an animation: packers name frames run_00, run_01 and so on.

They’re sorted naturally rather than as strings. Plain string order puts run_10 before run_2, which reverses the middle of every animation with more than nine frames, and only those.

final atlas = Atlas.grid(
image: 'hero.png',
imageWidth: 512,
imageHeight: 128,
cellWidth: 64,
cellHeight: 64,
name: 'run',
);
final run = SpriteAnimation.at(atlas.sequence('run'), fps: 12);
return run;

A SpriteAnimation is asked for a moment rather than advanced, for the same reasons as everything else with a playhead. A replay at a different frame rate is the same replay, and a test can assert which frame is showing at 0.75 seconds without running a loop.

loop and pingPong are properties of the animation rather than of the thing playing it, so a walk cycle and a one-shot are both just animations.

final sky = Parallax(const [
Layer(image: 'hills.png', depth: 0.1, drift: 2), // far off, moving on its own
Layer(image: 'trees.png', depth: 0.5),
Layer(image: 'ground.png', depth: 1.0), // the plane the game is on
]);
return sky.at(120, 3.5); // where each layer should be drawn

depth is how far away the layer is. 1 is the plane the game is on, distant hills a tenth, mid trees a half. Anything above one is a foreground that rushes past, which is the same trick used the other way round.

drift is how fast a layer moves on its own, in world units a second. Without it, every layer is still whenever the camera is, and a still sky reads as a painted backdrop rather than as weather.

at returns a list in the same order as the layers, so a caller can zip them without looking anything up.

packAtlas packs sprites you already have as pixels. It tries five placement heuristics and keeps whichever fills the pages best, trims transparent borders, pads and extrudes edges so filtering doesn’t bleed a neighbour in, spills onto further pages, and shares one rectangle between identical sprites. The same sprites pack the same way whatever order they arrive in. In the package’s benchmark, nine hundred sprites of mixed sizes fill sixteen pages at 92%, where a simple shelf packer needs twenty at 73%.

import 'dart:typed_data';
import 'package:orblit_sprite/orblit_sprite.dart';
AtlasSet pack(Uint8List coin, Uint8List gem) {
final result = packAtlas(
[
// RGBA8, straight alpha, one row after another from the top.
AtlasSprite(name: 'coin', width: 16, height: 16, pixels: coin),
AtlasSprite(name: 'gem', width: 24, height: 24, pixels: gem),
],
const AtlasPackOptions(maxPageSize: 2048, padding: 2),
);
for (final problem in result.problems) {
print(problem); // a sprite too big for a page even on its own
}
return result.toAtlasSet(imageName: (page) => 'sprites_$page.png');
}

Pass OrblitDeviceProfile.textureSizeBudget as maxPageSize when you know the device, so no page is bigger than its texture budget. packAtlasInBackground does the same work on an isolate, and writeAtlas writes the JSON that Atlas.read reads back.

For a folder of PNGs there’s a command:

Terminal window
dart run orblit_sprite:atlas_cook sprites/ --out build/atlas --prefix hero --max-page-size 1024

That writes hero0.png and hero0.json, then hero1.png and so on if it needs more pages. --rotate exists and is off by default for the reason in the caution above. It packs one folder and stops: there’s no cache and no import settings yet.

TileMap, TileLayer and Tileset read a map saved by Tiled as JSON: the orthogonal, uncompressed subset, which is what Tiled writes by default. Isometric and hexagonal maps, base64 or zlib layer data, object layers and external tileset files come back as null rather than as an empty map.

A tile map is read, not drawn. You can ask it what’s at a cell (isSolidAt, cellAt, TileLayer.at) and where a tile sits in its tileset (Tileset.rectOf), and that’s enough to build an OrblitSprites layer yourself. Nothing in the engine does that for you yet, and a tile map or parallax entity in a scene file is kept and written back but not drawn.