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.Litand one withMaterial.Unlitare 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
zIndexbut different textures are drawn grouped by texture, not in the order they were submitted. If two such sprites overlap, give them differentzIndexvalues. - Shapes and sprites at the same
zIndexkeep the order they were submitted in, so interleaving them breaks batches. Give them differentzIndexvalues 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
.fntfile 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
UIScaleto 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
ItemsSourcerebuilds every row. Assigning the same collection again does nothing. Refreshon a repeater rebuilds every row too. There is no per-item update for a collection changed in place, so after changing items in place, callRefreshonce, after all the changes.- A row whose item raises
PropertyChangedrefreshes 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,SubmittedShapesandSubmittedCallbackscount the draws submitted.BackendFlushescounts the batches drawn. Many flushes for a few hundred sprites means batches are being broken; see Drawing.PassMetricsgives the same counts for each pass, by pass name.ResourceAllocatorStatsgives the render targets the frame graph holds:Active,PooledandPeakAllocated.
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.