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.
Drawing sprites
Section titled “Drawing sprites”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
revisionchanges. Bump it when you’ve moved something. Leave it alone and the layer’s buffer stays where it is. transformandtintcost nothing to change. A scrolling backdrop is a layer sent once and moved withMatrix4.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.sharpturnssnapon unless you say otherwise, so pixel art lands on whole pixels and doesn’t shimmer as it moves.blend: OrblitSpriteBlend.addonly 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);Atlases
Section titled “Atlases”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.
Sequences from a sheet
Section titled “Sequences from a sheet”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;Sampled, like everything else
Section titled “Sampled, like everything else”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.
Parallax
Section titled “Parallax”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 drawndepth 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.
Packing your own
Section titled “Packing your own”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:
dart run orblit_sprite:atlas_cook sprites/ --out build/atlas --prefix hero --max-page-size 1024That 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.
Tile maps
Section titled “Tile maps”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.
