Skip to content

Rendering and cameras

Layer: 2D (goo2d)

Everything on this page is goo2d's. goo3d supplies its own equivalent, and the kernel underneath is the same either way.

How a frame happens

Rendering is two halves on two isolates, and the split is what keeps the Flutter side cheap:

flowchart LR
    subgraph G["game isolate"]
        R["<b>GameRenderer2D</b>, a GameSystem<br/>walk renderables<br/>sort by zIndex<br/>write geometry into a shared buffer"]
    end
    subgraph F["Flutter isolate"]
        V["<b>Game2D.buildView</b><br/>CustomPaint<br/>one Canvas.drawVertices"]
    end
    R -->|"shared buffer"| V

GameRenderer2D composes world transforms and writes vertex data into a shared native buffer. The Flutter side replays it as a single drawVertices call per frame — no save, restore, rotate, translate or drawImage anywhere in the replay path. The view is push-driven off tick notifications instead of polling vsync.

You declare neither half. Game2D and GameState2D bring both, which is what makes extends Game2D the whole opt-in for 2D rendering.

Making something draw

Two mixins and one declaration:

class Player extends EntityStruct
    with Transform2D, WorldTransform2D, Renderable2D {
  late final Sprite sprite;

  @override
  void describeSprites(SpriteDescriptor descriptor) {
    super.describeSprites(descriptor);
    sprite = descriptor.has(width: 64, height: 64, color: 0xFF4FC3F7);
  }
}

WorldTransform2D is required — the renderer reads world transforms, not local ones.

Sprite

Everything descriptor.has takes becomes a column, so every one of these is per-entity and writable at run time:

Sprite has({
  TextureAsset? texture,
  TextureFilter filter = TextureFilter.mipmap,
  SpriteFrame frame = SpriteFrame.full,
  int color = 0xFFFFFFFF,
  double width = 0,
  double height = 0,
  int zIndex = 0,
  bool visible = true,
  RelativeOffset2D pivot = RelativeOffset2D.center,
  RelativeOffset2D alignment = RelativeOffset2D.zero,
  NineSliceBorder nineSliceBorder = NineSliceBorder.none,
})
Field Meaning
texture The image, or null for a flat colour
color ARGB. Multiplied with the texture, so it tints — and is the whole colour when untextured
width, height Size in world units
zIndex Draw order. Higher draws later, on top
visible Per-entity on/off, tested before a draw record is ever built
pivot Where the sprite's origin sits within itself
alignment Offset relative to the entity's transform
frame The sub-rectangle of the texture to draw — atlases and sprite sheets
nineSliceBorder Stretchable borders for panels and bars
filter Sampling: mipmap, and the crisp option for pixel art

The values you pass to has are defaults for every new row, not fixed values:

sprite
  ..color[entity] = 0xFFFF0000
  ..zIndex[entity] = 1000
  ..visible[entity] = false;

Hiding is a toggle, not a removal

visible[entity] = false drops the sprite before it becomes a draw record. There is no "remove the renderer component" — see Coming from Unity, Godot or Flutter.

Which way a pivot moves the sprite

A pivot's fraction is measured from the texture's top-left, so a fractionY of 0 is the top edge and 1 is the bottom. That is texture space, and texture space starts at the top in every atlas you are likely to import.

The world it draws into is y-up. The two meet in one fact worth stating plainly: moving the pivot down the texture lifts the sprite up in the world. The pivot is the point the transform origin sits on, so pushing it toward the bottom of the image leaves more of the image above the origin.

sprite.setPivot(entity, const RelativeOffset2D(fractionX: 0.5, fractionY: 1));

That anchors a character at its feet. fractionY of 1 is the bottom edge of the texture, and the sprite stands above the entity's position.

The offset you add on top runs the same way, and this is the part that decides where a collider goes. A pivot offsetY of +20 draws the sprite 20 units higher; a Collider2D offset of +20 puts a body 20 units higher. So a collider meant to cover an off-centre sprite takes the same sign — and the same number, when the pivot was nudged with an offset instead of a fraction:

sprite.setPivot(
  entity,
  const RelativeOffset2D(fractionX: 0.5, fractionY: 0.5, offsetY: 20),
);
box.offsetY[entity] = 20; // matches, and +20 is up for both

Several sprites on one entity

Renderable2D is a multi-component, so a prefab can declare more than one:

@override
void describeSprites(SpriteDescriptor descriptor) {
  super.describeSprites(descriptor);
  body = descriptor.has(width: 64, height: 64, texture: bodyTexture);
  muzzleFlash = descriptor.has(width: 32, height: 32, texture: flashTexture,
                               visible: false, zIndex: 10);
}

Toggle muzzleFlash.visible[entity] for a frame instead of spawning and destroying an entity.

Textures

A texture comes from the generated asset enum. Declare the asset, then hand the handle to the sprite:

class Player extends EntityStruct
    with Transform2D, WorldTransform2D, Renderable2D {
  late final TextureAsset texture;
  late final Sprite sprite;

  @override
  void describeAssets(AssetDescriptor descriptor) {
    super.describeAssets(descriptor);
    texture = descriptor.has(Textures.spritesPlayer);
  }

  @override
  void describeSprites(SpriteDescriptor descriptor) {
    super.describeSprites(descriptor);
    sprite = descriptor.has(width: 64, height: 64, texture: texture);
  }
}

Declare the asset on whatever uses it. has is idempotent per identity and prefabs share their scene's descriptor, so declaring the same texture in a prefab and its scene yields the identical handle — one address, one decode. See Assets.

Atlases and sprite sheets

SpriteFrame selects a sub-rectangle of the texture, so many sprites can share one decoded image — one upload, one batch. A frame is stored as fractions of the texture, and the two named constructors do the division for you at declare time:

// A uniform sheet: 8 columns x 4 rows, cell 5 (row-major).
const walk0 = SpriteFrame.grid(columns: 8, rows: 4, index: 5);

// A packed atlas: a pixel rectangle on a sheet whose size you know.
const buttonFace = SpriteFrame.pixels(
  x: 128, y: 64, width: 96, height: 32,
  sheetWidth: 512, sheetHeight: 512,
);

// The default: the whole texture.
const whole = SpriteFrame.full;

Both are const, so a frame table costs nothing at run time:

class Player extends EntityStruct
    with Transform2D, WorldTransform2D, Renderable2D {
  final animTime = Field.float64();

  late final Sprite sprite;

  static const List<SpriteFrame> walkCycle = <SpriteFrame>[
    SpriteFrame.grid(columns: 8, rows: 4, index: 0),
    SpriteFrame.grid(columns: 8, rows: 4, index: 1),
    SpriteFrame.grid(columns: 8, rows: 4, index: 2),
    SpriteFrame.grid(columns: 8, rows: 4, index: 3),
  ];

  @override
  void describeSprites(SpriteDescriptor descriptor) {
    super.describeSprites(descriptor);
    sprite = descriptor.has(width: 64, height: 64);
  }
}

Then advance it per entity from a system. animTime is a Field.float64() column on the prefab, and 12 is the frame rate:

for (final group in players.groups()) {
  final player = group.get<Player>();
  for (final entity in group) {
    final t = player.animTime[entity] + dt;
    player.animTime[entity] = t;
    final step = (t * 12).floor() % Player.walkCycle.length;
    player.sprite.frame[entity] = Player.walkCycle[step];
  }
}

SpriteFrame.grid is exact whatever the image's pixel size — the constructor never asks how big the source is, because it does not need to know.

Cameras

A camera is an entity: Transform2D, WorldTransform2D and Camera.

class Eye() extends EntityStruct with Transform2D, WorldTransform2D, Camera;
Column Meaning
zoom World units per screen pixel. 1 is 1:1, 2 zooms in, 0.5 out
view Which declared CameraView this camera fills, or null for none
final camera = scene.addEntity(eye);
eye.view[camera] = game.defaultCamera;
eye.zoom[camera] = 2;

view is typed, not an integer — a stray int does not compile there.

Views

A view is a surface a camera can fill. Game2D declares one for you:

GameView(camera: game.defaultCamera)

Declare more when you want split screen, a minimap, or a second window:

class MyGame extends Game2D {
  late final CameraView minimap;

  @override
  void describeCameras(CameraDescriptor descriptor) {
    super.describeCameras(descriptor);
    minimap = descriptor.has();
  }
}

Each view sizes and allocates its own per-view storage, and each draws the scene its own camera is in — so two views can be looking at different scenes at the same instant.

One camera per view

Two enabled cameras pointing at the same view trips a debug assert. A camera defines that view's origin, so a second one has no meaning. In release the first in query order wins.

No camera at all

A game with no active camera draws at the origin with a zoom of 1, and the whole world is drawn. A game that has not placed a camera yet shows something, not a black screen.

GameView.headless(game: game) is the other legitimate shape: a HUD-only or headless-plus-Flutter game, with no camera and nothing painted.

Coordinate conversion

Going between a GameView pixel and world space is a first-class operation — picking, placing UI at a world position, dragging:

final projection = getSystem<MousePickingSystem>().projection;
final worldX = projection.viewToWorldX(localX);
final worldY = projection.viewToWorldY(localY);
final viewX  = projection.worldToViewX(entityWorldX);

CameraProjection re-resolves the active camera and the view size each tick, so it always reflects the current zoom and viewport.

The y sign flips here and nowhere else. World +Y is up and a GameView pixel's y grows downward, so these four methods are the whole of the conversion between the two. Use them rather than world - cameraOrigin by hand, or a tooltip you place at a world position ends up mirrored about the middle of the view.

A zoom of zero maps the whole world onto one pixel, so the inverse reports the camera's own origin, not an infinity that would poison every downstream comparison silently.

Budgets

class MyGame extends Game2D {
  @override
  int get maxSpritesPerTick => 24000;   // default 4096
}

This sizes the native frame buffer. Hitting the cap truncates the batch, which looks exactly like the renderer getting slower unless you can see the count — so the renderer exposes how many sprites it actually emitted, and a debug overlay showing it is worth building early.

The renderer also reports its three phases — walking renderables into the draw queue, sorting by z, and writing geometry — separately instead of as one presentation total, because they are three unrelated costs with three unrelated fixes and one number cannot direct any of them. See Performance.

In-game UI, and when to avoid it

The engine can draw buttons and panels — NineSliceBorder stretches borders for exactly that, and MouseReceiver below gives an entity pointer events.

Prefer Flutter widgets anyway. Your HUD, menus and inventories should be ordinary widgets over the GameView; make a UI element an entity only when it is as interactive as the game itself — pinned to a world position, occluded by the world, or hit tested in world coordinates. See Where your UI belongs.

Mouse picking

MouseReceiver gives an entity pointer events, resolved against its world transform and sprite bounds by MousePickingSystem:

class Button extends EntityStruct
    with Transform2D, WorldTransform2D, Renderable2D, MouseReceiver {
  @override
  void onMouseEnter(MouseEvent event) { }
  @override
  void onMouseHover(MouseEvent event) { }
  @override
  void onMouseExit(MouseEvent event) { }
  @override
  void onMousePressed(MouseEvent event) { }
  @override
  void onMouseReleased(MouseEvent event) { }
}

Enter, hover, exit, pressed and released are separate phases, so hover feedback does not have to be reconstructed from raw positions.

Picking is scoped the way drawing is: the pointer hits only entities in the scene the view's camera is in. A second scene resident behind the one on screen simulates, but nothing in it can be clicked.


Next

Input →