Table of Contents

Performance

A game's cost tends to come from a few places: how many draw calls it makes, how often textures and fonts are loaded, how many lights a lit sprite carries, how often a view is rebuilt, and how much a packet costs to send. This page covers each of them in turn, and ends with the figures the engine exposes so that you can see where the time goes. Measure before changing anything: the cheap change is often not the one that matters.

Drawing

An encoder draws its commands in order, and draws in as few calls as it can. Consecutive sprites that share a texture and a material go out together, as one batch. Anything in between ends the batch.

These break a batch:

  • A change of texture. Sub-textures and atlas regions count as the texture they are cut from, so sprites from one sheet batch together however many regions they use.
  • A change of material. A sprite with Material.Lit and one with Material.Unlit are separate batches.
  • A shape between sprites. The sprites before it are drawn, then the shape, then the sprites after it start a new batch. Interleaving sprites and shapes therefore costs a batch for each switch.
  • A callback. This includes the work of a full-screen effect, which is a callback.
  • Clear. It also throws away what was submitted before it, so nothing before a clear is drawn.

Each encoder is drawn when it is disposed, so using var encoder = Graphics2D.Begin(); in Draw gives one set of batches for everything in that scope. Two encoders in a row do not share a batch.

Order and batching

Draws are kept in the order they were submitted, unless a draw in the encoder has a zIndex other than 0. Only then does the encoder sort. It sorts by zIndex, then by material and texture, then by submission order. Because of that sort:

  • In an encoder that sorts, sprites with the same zIndex but different textures are drawn grouped by texture, not in the order they were submitted. If two such sprites overlap, give them different zIndex values.
  • Shapes and sprites at the same zIndex keep the order they were submitted in, so interleaving them breaks batches. Give them different zIndex values if you want the shapes grouped together.

Some practical rules follow from this:

  • Put sprites from one sheet in an atlas, and draw them from the atlas. See Content.
  • Draw a layer's sprites together, then its shapes, where the order between the two does not matter.
  • Keep the number of materials small. Each material is its own batch.
  • Prefer a sprite to a shape for something drawn many times. A shape is a separate draw call on OpenGL, while a sprite drawn from a texture batches with its neighbours.

A run of sprites has no length limit. Both renderers draw it in batches of at most 16,384 sprites, so a tile map of 128 by 128 tiles is one batch and a larger map is simply several. A batch is also split where the texture or the material changes, as above.

Shapes

On OpenGL a material's shapes are collected as triangles, lines and points, in the order they were submitted, and each run of one primitive at one thickness is drawn with one call. A circle or outline joins such a run like any other shape. The renderer keeps its vertex buffer between batches, so a switch from sprites to shapes does not create one. On the Vulkan renderer the lines and the triangles of one material's shapes are each gathered into one list, drawn when the batch ends. Either way, a shape between sprites still costs a batch, so draw shapes together where the order allows.

Text

Each glyph is drawn as one sprite, from the page of the font it belongs to. A line of text is therefore a run of sprites from the same page texture, and it batches like any other sprite. Text is not cached: it is laid out and drawn again every frame.

  • A .fnt file loads once. Its pages are textures, and nothing else is rasterised.
  • A TrueType font, from GetTrueTypeFont, is rasterised when it is loaded. Every character in the Basic Multilingual Plane that the font has a glyph for is drawn into atlas pages of 1024 pixels wide, and the load takes longer for a font that covers more characters.
  • Each size of a TrueType font is a separate font with its own pages. Five sizes of one font are five sets of pages. Use one or two sizes per font where you can.

MeasureText does its work on every call, and Wrap builds a new string each time it is called. For text that changes rarely, keep the wrapped string and only wrap again when the text or the width changes:

private string _wrapSource;
private string _wrapped;

private string WrappedText(string text, int width)
{
  if (text != _wrapSource)
  {
    _wrapSource = text;
    _wrapped = _font.Wrap(text, width);
  }

  return _wrapped;
}

For text that does not change at all, such as a title, draw it into a render target once and draw the target each frame. Render passes shows how to keep a target over many frames.

Lights and effects

Lights

A lit sprite, one with Material.Lit or Material.Veil, is lit by up to 32 point lights. The engine uses the first 32 in the list, and does not check whether a light reaches anything on screen. A light off screen costs the same as one on it, so take lights out of the list when they are no longer needed.

The lights are sent to the shader each time a batch changes to a lit material from another material, not once per frame and not once per sprite. A run of lit sprites therefore pays once. Keep lit sprites together, by zIndex or by layer, so that there are few switches. Unlit sprites and shapes do not use the lights at all, and cost nothing for them.

SetGlobalAmbientColor and SetGreyscale cost nothing of their own. They set values that go to the shader with each lit batch.

Full-screen effects and passes

A full-screen effect draws in a callback, so it ends the batch before it, and the batch after it starts again. Put it where it has to be, and once per frame.

Each pass that draws to a different target starts its own encoders, so passes do not share batches. Use a pass when the target really is different, such as a scene drawn to a texture and then shown through a post-processing effect. Do not add a pass for each small effect.

ConfigureRenderGraph runs every frame, and builds the graph anew each time. Keep it to adding the passes. Do not load content or call RenderToTarget in it: create what you need once in Initialize and keep it. The frame graph reuses the targets it makes for transient resources, but IOffscreenRenderer.RenderToTarget creates a new target on each call and leaves it to you to dispose, so call it once, not every frame. Graph validation is Full in a debug build and off in a release build, so timings in a debug build include checks that a release build does not make.

Content

Textures are cached by the file they resolve to, so SHIP.png and ship.png on a case-sensitive system are one texture and load once. A name is worked out to its file the first time it is asked for, and after that a request is a dictionary lookup.

Sub-textures and atlas cells are cached: indexing an atlas gives the same SubTexture each time for the same region or index. Keep the result rather than indexing every frame:

_hero = _atlas["hero"];
_coin = _atlas["coin"];

The same goes for GetSubTexture, which builds its cache key on every call. Get the sub-textures once in Initialize, or when a scene starts.

GetBMFont and GetTrueTypeFont check the cache first, so a font that is loaded is a dictionary lookup, but each call still builds its cache key from the name. Call them once and keep the IFont.

Load what a scene needs when the scene starts, rather than the first time it draws. A texture that is first asked for in the middle of play loads, and decodes, during that frame. A sheet that is no longer needed can be unloaded with UnloadTexture or UnloadFont, and it loads again if it is asked for later. Do not draw with a texture after unloading it. That includes the sub-textures and atlases cut from it, which still hold the unloaded texture.

UI

A view is built from its view model, and rebuilt in these cases:

  • Setting UIScale to a new value rebuilds every view on the stack. Do not change it in a loop, such as during a slider drag.
  • Assigning a different collection to a repeater's ItemsSource rebuilds every row. Assigning the same collection again does nothing.
  • Refresh on a repeater rebuilds every row too. There is no per-item update for a collection changed in place, so after changing items in place, call Refresh once, after all the changes.
  • A row whose item raises PropertyChanged refreshes only that row's bindings.

A repeater builds a control tree for every item, including the ones scrolled out of sight. Only the rows that fit are laid out and drawn, but the rest still cost their controls. For a very long list, show a page of the data at a time, and build the rest when the user scrolls to it.

Each time the view model raises PropertyChanged, every binding in the view is read again, through reflection, and pushed to its control. Raise the change once after a group of changes rather than once per change, and keep the bindings on a view to the ones it shows. A label skips the work when the new value is the same as the one it has: setting it to the text it already shows does nothing further. See Bindings and commands.

Networking

Choose the delivery mode for each kind of data, as Delivery modes describes. Frequent state, such as a position, is cheapest as SendUnreliableSequenced, because a late packet is dropped and nothing is sent again for it.

Packets cost time to send. Every public property is read through reflection each time a packet is sent, and a packet's type name is hashed each time too, so a packet with many properties, or sent many times a second, adds up. The library does not keep a cache of either. What helps:

  • Send fewer, larger packets rather than many small ones. Send the changes made in a tick as one packet, not one packet per change. There is no batching call; combine the changes in your code.
  • Send only what changed. A packet that carries the whole world every tick is the costliest kind of update.
  • Put bulk binary data in a byte[], which is sent as raw bytes, rather than in a list of numbers.
  • Keep unreliable packets small. Delivery modes gives the limit for them.

Use GetStatistics to read the totals and the round-trip time about once a second, to show a readout or to adjust how often you send. The byte counts cover what the library writes, not what QUIC adds beneath it. Reading them every frame is wasted work.

Measuring

Frame time

The Game class does not report the time of the last frame. Time the parts you care about with a Stopwatch in your own Update and Draw overrides, and log the figures at a level you can switch off:

private readonly Stopwatch _drawTimer = new();

protected override void Draw(IGameTime gameTime)
{
  _drawTimer.Restart();
  // ... the drawing ...
  _drawTimer.Stop();
  Logger.LogTrace("Draw took {Milliseconds} ms.", _drawTimer.Elapsed.TotalMilliseconds);
}

Render counters

IRenderEncoderMetrics counts what the encoders are asked to draw. Get it with GetService:

  • SubmittedSprites, SubmittedShapes and SubmittedCallbacks count the draws submitted.
  • BackendFlushes counts the batches drawn. Many flushes for a few hundred sprites means batches are being broken; see Drawing.
  • PassMetrics gives the same counts for each pass, by pass name.
  • ResourceAllocatorStats gives the render targets the frame graph holds: Active, Pooled and PeakAllocated.

The engine resets these counters when each frame's render graph starts, so they cover the frame being drawn. An encoder adds its counts when it is drawn, which is when its scope ends, so a count read partway through a frame leaves out the encoders still open. To report the previous frame, read them in BeginFrame, which runs before the frame draws; the counts then are the previous frame's. This needs Microsoft.Extensions.Logging and BSGameLib.Game.Rendering:

protected override void BeginFrame(IGameTime gameTime)
{
  // The counters hold last frame's figures here, before this frame draws.
  var metrics = GetService<IRenderEncoderMetrics>();
  Logger.LogDebug(
    "Last frame: {Sprites} sprites, {Shapes} shapes, {Flushes} flushes; {Active} render targets in use.",
    metrics.SubmittedSprites,
    metrics.SubmittedShapes,
    metrics.BackendFlushes,
    metrics.ResourceAllocatorStats.Active);
  foreach (var pass in metrics.PassMetrics)
    Logger.LogDebug("Pass {Name}: {Sprites} sprites, {Flushes} flushes.", pass.Name, pass.SubmittedSprites, pass.BackendFlushes);

  base.BeginFrame(gameTime);
}

IRenderMetrics, also from the services, holds LastFrameDrawCalls and LastFrameVertices for the last frame the engine completed, and the last 256 frames of each in DrawCallHistory and VertexHistory. They count sprite batches only: a draw call is one batch drawn, and vertices are four per sprite. Shapes are not counted there; IRenderEncoderMetrics counts them.

Logging

Logger in a game is an ILogger<Game>. Nothing is written until a logging provider is added in Configure; Project setup shows the console provider. Content loading logs at Debug when a texture or font is loaded from disk, and at Trace on every request, cache hits included. Turn Trace on only while looking at content loading, because a busy scene then writes a great many lines.

Network figures

GetStatistics gives the totals since the adapter started, and the round-trip time of each open connection once it has been measured. See The client.