HUD & developer overlay

A HUD layer draws over a live world and never takes an event: a reticule, a health bar, an ammunition count, a line of text that fades. The developer overlay is one of these, fed by registered providers, and it is where the frame rate is now drawn. Both are drawn beneath any overlay panel that is open, by the same batched renderer, in the same handful of draw calls.

A HUD is not a panel

The overlay stack is modal: while a panel is up, nothing under it hears anything. That is exactly right for a settings screen and exactly wrong for a health bar. So the two are different things:

HUD layerOverlay panel
InputNever receives any The top one takes it; a modal one stops the world hearing it
FocusNone — nothing in it is focusable Tab order, accelerators, an Enter default
DrawnEvery frame, under the panels When something is open, over the HUD
Laid outEvery frame, because its values change When it opens or the window resizes
PlacedBy anchor: a corner, an edge, the middle Centred in the window

Every context has HUD layers, because every context has a developer overlay. A context gains panels by mixing in OpenGLContext.ui.overlay.OverlayMixin.

Putting a HUD on the frame

A layer is a tree of widgets, each carrying the corner it belongs in. Add it to the context and it is drawn from the next frame on:

from OpenGLContext.ui.hudwidgets import (
    BarMeter, Crosshair, HUDGroup, HUDLayer, MessageQueue, Readout, TextBlock,
)

self.health = BarMeter(label='HEALTH', value=100, maximum=100)
self.ammo = Readout(label='AMMO', value='50', anchor='bottom-right')
self.messages = MessageQueue(anchor='top', duration=4.0)

self.hud = HUDLayer(children=[
    Crosshair(shape='cross', gap=5, length=7),        # anchored 'center'
    HUDGroup(anchor='bottom-left', spacing=4, children=[self.health]),
    self.ammo,
    self.messages,
])
self.addHUDLayer(self.hud)

After that it is data: write self.health.value = 40 and the meter turns amber, self.messages.post('PICKED UP A SHOTGUN') and a line appears and fades. Nothing has to be redrawn by hand, because the layer is measured and painted afresh each frame.

Placement is by anchor

Health in one corner, ammunition in another, the reticule in the middle: no row-and-column arithmetic describes that as well as naming the corner does. Every HUD widget carries two fields:

FieldMeaning
anchor One of top-left, top, top-right, left, center, right, bottom-left, bottom, bottom-right. Anything else is read as center, so a typo puts the element somewhere visible.
offset Pixels away from that anchor, +x right and +y up, so an offset always moves an element away from the corner it is anchored to whichever corner that is.

HUDLayer.margin keeps everything clear of the window edge. A child with no anchor at all — an ordinary Row or Column — is given the whole layer and lays itself out normally, so the ordinary layout containers work inside a HUD.

Every pixel measurement here — margins, offsets, a reticule's gap, a meter's height — is at the reference font size and is multiplied by the interface scale, exactly as the skin's are. A HUD authored on a 1080p display is the same size in the eye at 4K. See Everything scales with the window.

The widgets

Crosshair — the reticule

The reticule belongs to the weapon, not to the game. Everything about it is a field, so a table of weapons names one of these each and switching weapon is switching this node rather than branching in the drawing code.

FieldUnitsDefaultMeaning
shapecross cross, dot, cross-dot, circle or none. none is a real choice: some weapons aim down their own sights.
gappx5 Clear space in the middle. The gap is the point of a crosshair: it leaves what is being aimed at visible.
lengthpx7One arm.
thicknesspx2Arm width.
dotSizepx2 The centre dot, for the shapes that have one.
spreadpx0 Extra gap from the weapon's current accuracy. Widens the reticule rather than moving it, so what the player sees is the size of the area a shot might land in.
hitDurations0.35 How long the hit mark stays up after crosshair.hit().
hitSizepx4How big it is.

The shape is drawn from axis-aligned rectangles — four arms, or four arcs for a circle — because that is what the overlay renderer draws, and at the few pixels a reticule occupies the difference is invisible while the batch stays one draw call.

BarMeter — health, armour, a charge

A value against a maximum, coloured by where it sits against two thresholds: warnFraction (0.5) and criticalFraction (0.25) pick the skin's hudGood, hudWarn and hudCritical. Three colours rather than a gradient, because what a player reads off a meter at speed is a state — fine, low, about to matter — and a continuous ramp says none of those clearly. Setting color overrides the thresholds entirely.

barWidth/barHeight are its natural size; the track stretches to whatever room the meter is given, so a meter in a stretched group stretches with it. A label is drawn to its left and takes room from the track; showValue draws the number over it.

A meter can react rather than merely update. meter.flash(now) lights the whole track and fades over flashDuration (0.35 s) from flashStrength (0.75). A number that changed silently in a corner nobody is looking at is not feedback, and the flash is what makes the change something a player notices out of the corner of an eye. Whether a change is worth flashing about is the game's rule and not the meter's — health lost matters and health gained from a pickup may not — so the game asks:

if value < float(meter.value):    # a drop, not a pickup
    meter.flash(now)
meter.value = value

The whole track lights, not the filled part, so a meter that has just been emptied still flashes: the moment health reaches nothing is the one a player most needs to see.

DamageIndicator — which way the hit came from

The direction is the whole point. How much health was lost is already on the meter and in the number beside it; what a player cannot see, and must act on within about a second, is where the shooter is standing. So a hit is washed onto the screen edge the player would turn towards to face it.

indicator.hurt(bearing=-math.pi / 2, intensity=0.6, now=now)   # from the left

A bearing is radians from straight ahead, positive to the right, so +pi/2 is directly to the right and pi is directly behind — which is the bottom of the screen, because that is where a player looks for what they cannot see. Each of the four edges takes the share of a hit that faces it, so the wash slides from one edge to the next as an opponent circles rather than snapping between them; a flicker at the corner would read as a fault in the game and not as a shooter moving.

FieldUnitsDefaultMeaning
durations1.1 How long one hit takes to fade to nothing. Long enough to be seen through the flinch of being shot, short enough that it is not still up when the player has already turned.
thicknesspx56 How far in from the edge the wash reaches at full strength.
steps4 How many strips the wash is drawn from. It is a gradient rather than a band because a hard-edged block over the world reads as a rendering fault.
capacity8 The most shown at once. A firefight can ask for dozens and the ones underneath contribute nothing a player can see.

intensity is 0 to 1 and an intensity of nothing draws nothing — a hit an armour absorbed entirely is not a hit to flash about. Several hits are shown at once and each fades on its own clock, so being caught in a crossfire looks like being caught in a crossfire. clear() drops them all, which is what a respawn wants: a fresh body is not still bleeding from the last one's wounds.

ScreenWash — a colour over the whole viewport

The counterpart to DamageIndicator, and the difference between them is the whole reason there are two. That one has a direction to give and washes the edge a player must turn towards. This one has none: it is for the states where the view itself has changed rather than where something has happened in it — being dead, being under water, the moment a screen fades.

wash = ScreenWash(colour=(0.65, 0.04, 0.04))
wash.strength = 0.42        # driven per frame; 0 draws nothing at all
FieldUnitsDefaultMeaning
colourrgb(1, 0, 0) What colour the world is seen through.
strength0–10.0 How solid it is, and the whole of the design decision. A wash a player can still read the room through is information; one they cannot is a curtain, and the only state that earns a curtain is one where there is nothing left to read.

A strength of nothing draws nothing rather than a transparent rectangle: this covers the whole screen and is up on most frames of most games at zero, so being switched off has to cost a comparison and not a draw. Drive strength per frame rather than switching it on at the event, so it can rise with whatever it accompanies — a wash that snapped on ahead of the camera movement it belongs to reads as a screen effect rather than as the thing that happened.

Readout — an icon and a number

label, value and an optional icon (a NineSlice, drawn at iconSize). critical is the game saying “this one matters now” — the last few rounds, the last seconds — and it is a flag rather than a colour so the skin still decides what that looks like.

TextBlock — several lines in a corner

The counterpart to Readout, which is one line built from a label and a value. This is prose the application composed itself — what is loaded and what the keys do — and the lines are a field rather than one string with newlines in it, so a caller can rewrite one of them. critical marks the whole block as saying something has gone wrong.

caption = TextBlock(anchor='bottom-right', align='right',
                    lines=['model.glb', '[1/3] aerial'])

oglc-view's caption is one of these: see OpenGLContext.viewer.caption. Note the corner — the developer overlay is anchored top-left and grows down the left edge as sections are registered, so a status line anywhere along that side is a status line written over it.

MessageQueue — pickups, frags, warnings

Newest first, each line carrying its own clock, so a warning posted with a long life is not cut short by a pickup posted after it:

queue.post('YOU HAVE THE FLAG', duration=6.0)
queue.post('PICKED UP A SHOTGUN')          # the queue's own duration

duration is how long a line is shown and fade is how much of the end of that is spent fading out; the fade arrives in the colour's alpha, so nothing downstream has to know about time. capacity is how many are shown at once — older ones are dropped when a new one arrives, because a HUD that scrolls is a HUD nobody reads.

The clock

Anything that fades, expires or flashes is driven by HUDLayer.tick(now), which the context calls once a frame before drawing. Passing the time in rather than reading a clock inside each widget is what makes all of this testable — and what lets a game drive its HUD from its own simulation clock rather than from the wall.

One clock, and it must be the layer's. The context ticks with time.monotonic(), so a game that marks a hit with time.time() and lets the layer expire it computes every fade from the difference between two clocks — about fifty years. On screen that is a hit mark and a damage wash that never go away, and an alpha of a hundred million. Give the widgets the same clock the layer gets, and give it from one place:

def now():
    """The clock every reading on this HUD is taken against."""
    return time.monotonic()
queue.post('PICKED UP A SHOTGUN', now=100.0)
layer.tick(101.0);  assert queue.entries(101.0)      # still up
layer.tick(105.0);  assert not queue.entries(105.0)  # gone

The developer overlay

One panel holding everything a developer wants to see and a player never should, so that a game's HUD can be built out of game information only. It is a HUD layer, so it can be left up while playing: it takes no input and blocks nothing.

It is fed by registered providers. A provider is a callable returning name/value pairs and knowing nothing about drawing, so a new subsystem appears in the overlay by registering rather than by anyone editing the overlay:

context.debugOverlay.register('Map', lambda: [
    ('name', loaded.name),
    ('family', loaded.family),
    ('position', tuple(camera.position[:3])),
], order=40)

Values are formatted by the overlay, so a provider hands over whatever it has: a float becomes 0.33 (and 60 rather than 60.00), a bool becomes yes/no, a vector becomes its components, None becomes -. order sorts the sections, low first; equal orders keep the order they registered in. Registering a title twice replaces it, so a reloaded subsystem does not grow a second copy of its section.

A provider that raises becomes an error row rather than taking the frame down, and a provider with nothing to say is left out entirely rather than drawn as an empty heading. This is diagnostic equipment: a diagnostic that breaks the thing it is measuring is worse than none.

What ships with it

SectionRows
Frame Frame rate (the windowed median, not the lifetime average), frame time in milliseconds, frames drawn, the viewport.
Loop Wall-clock cost of the whole main loop, and where it went — see below. Left out by backends that run their own loop and never open an iteration.
Render Profile, and whether shadows, IBL, bloom, transmission, instancing and vsync are on; then what the last frame cost — shapes gathered, draw calls issued, and how many shapes collapsed into how many instanced groups.
View Camera position, and a character controller's grounded state, velocity and mode when there is one.

physics_provider(lambda: world) is also supplied, for body and contact counts, and simulation_provider(lambda: manager) for a background physics thread (OpenGLContext.physics.threaded.ThreadedPhysicsManager) — the rate it is achieving against the rate it asked for, its total ticks, and any it had to drop. A thread that is not getting the turns it asked for produces a world moving in slow motion, which is indistinguishable from wrong gravity or a wrong timestep until those two numbers are side by side. Both take a callable rather than the object because a world is replaced when the next level loads, and a provider holding the first would report on it forever. Neither is registered by default: a context does not necessarily have either.

Triangle counts are deliberately absent. The render pass does not know them — a geometry node does — and instrumenting every render() in the system to find out would cost more than the answer is worth. A number that is not counted is not reported rather than guessed at. See OpenGLContext/passes/renderstats.py.

When the frame rate says everything is fine and it is not

The frame rate answers a narrower question than it looks like it answers. FrameCounter times the inside of OnDraw, it is only told about frames that produced a visible change, and the rate it publishes is a median over a window — deliberately outlier-proof, so that a first-frame shader compile or a synchronous model load does not drag the number down for ever. Each of those is right for “how fast is the renderer”, and each of them hides a stutter.

A backend’s loop does more than draw. It pumps the window system’s events, it runs OnIdle — which is where an application’s whole simulation usually lives — and it waits for a redraw request. None of that is inside the frame counter’s stopwatch, so an application can crawl visibly at a few updates a second while the overlay reports sixty, with no number on the screen contradicting the other.

OpenGLContext.looptrace.LoopTrace measures the other thing: wall-clock time per loop iteration, divided among named phases. Every context has one as context.loopTrace; the GLFW backend drives it from MainLoop and OnDraw divides its share further. The Loop section reports:

RowWhat it says
loop fps Iterations per second of wall clock — the rate the player’s hands feel. This disagreeing with fps above is the diagnosis: the renderer is keeping up and something outside it is not.
loop ms, worst ms Median and worst iteration in the window. A healthy median beside a worst ten times larger is a loop that stutters, which is what the median alone can never say.
stalls How many iterations crossed the stall threshold, counted since the loop started so it outlives the window it happened in.
last stall The phase that ate the most recent one, as idle 812ms.
poll, repeats, idle, wait, draw, cascade, render Mean milliseconds per iteration in each phase, worst first.

Phases divide the iteration; they do not overlap it. A phase is charged only its own time and the time inside a nested phase belongs to the child, so every phase of an iteration adds up to that iteration’s wall clock and the largest is the culprit by construction. wait is bounded by drawPollTimeout, so a large wait is a quiet loop and never a stalled one.

An application should subdivide idle. The engine’s phases can only ever name it, because OnIdle is where an application’s frame goes — true, and useless. context.tracePhase(name) opens a phase from anywhere, nests inside whatever is already open, and answers a do-nothing context manager when nothing is measuring, so the caller writes the with and not the branch:

def OnIdle(self):
    with self.tracePhase('character'):
        self.player.update(dt)
    with self.tracePhase('match'):
        self.arena.step(dt)

Those divide idle rather than adding to it, so idle’s own row falls to whatever is left over. A phase that is always under a millisecond costs one clock read and earns its place the first time it is not.

Counting is always on and costs a few clock reads per iteration. Reporting is opt-in:

VariableEffect
OPENGLCONTEXT_STALL_MS What counts as a stall, in milliseconds; the default is 50 (twenty frames a second). Setting it also switches logging on — asking for a threshold is asking to be told when it is crossed. A value that is not a number is a warning and the default, never a failure to start.
OPENGLCONTEXT_TRACE_STALLS Log every stall’s phase breakdown at WARNING, keeping the default threshold.
OPENGLCONTEXT_STALL_MS=40 python -m twig_bb
WARNING OpenGLContext.looptrace: main loop stalled 912ms: idle 901ms, render 9ms, poll 1ms

Neither variable changes what a frame looks like, so both are inherited by a subprocess capture rather than cleared with the rendering variables — a diagnostic a subprocess silently dropped would be no diagnostic.

Recording a slow period, and reading it back

The phase breakdown names a subsystem. It cannot name the code inside it, and it never will: by the time the iteration closes, the stack that would have said has already unwound. The overlay has the same limit from the other end — it shows the present, and a stutter is something you want to look at afterwards, from a recording.

OPENGLCONTEXT_STALL_TRACE=<path> records one. OpenGLContext.stalltrace puts a watcher thread on the main thread’s Python stack, sampling it only while an iteration is already overrunning — a profiler that runs during the frames that are fine would be spending the one currency the question is about — and writes a JSON-lines file:

OPENGLCONTEXT_STALL_TRACE=/tmp/stalls.jsonl python -m twig_bb
python -m OpenGLContext.stalltrace /tmp/stalls.jsonl

The unit is an episode, not a frame. Consecutive slow iterations are gathered into one record, because “the game went unplayable for four seconds” is one thing that happened, not two hundred, and a file with a line per frame is a log rather than a diagnosis. A run of slow frames with the odd good one in it stays a single episode.

In each recordWhat it says
at, seconds, iterations, stalled When the slow period began, how long it lasted, and how much of it was actually slow.
worst_ms, mean_ms, baseline_ms The worst and mean iteration, and what the loop had been managing before it went wrong — without which “slow” has nothing to be slow compared to.
phases_msThe breakdown, averaged over the episode.
functions The answer. Per function, the share of samples where it was running (self) and the share where it was anywhere on the stack (cumulative).
hot The whole stacks that held the most samples — the evidence, and the route by which the expensive function was reached.
context Every section of the developer overlay at the moment the episode opened, so the trace records the map, the player, the renderer’s counts and whatever the game registered, without anyone writing a second description that can drift from the first.
samples How many were taken, and incomplete when the start of the episode fell out of the sampler’s buffer. A truncated record that does not admit it reads as the whole story.

Report per function, not per stack. One expensive function is usually reached by a dozen routes and from a dozen lines; grouping only by whole stack splits it across all of them, so something holding sixty per cent of a stall reads as a dozen entries at five per cent and the trace looks like it found nothing. functions undoes that fragmentation, and hot keeps the routes as evidence:

  where 28 samples were, by function:
        self    cum  function
       53.6%  53.6%  collide.py _closest_point_on_triangle
       10.7%  75.0%  collide.py capsule_triangle
       10.7%  10.7%  collide.py _closest_segment_segment

An episode is written as it closes, and flushed, so a run that ends in a kill -9 keeps every episode before the last. A slow period is also cut and written every few seconds even while it continues (the next part is marked continues), because a session that ends while it is struggling is the commonest way for one to end — and that final episode is the one worth having.

A path that cannot be written is a warning and a disabled journal. This is diagnostic equipment: it does not get to be the reason a game will not start.

Showing and hiding it

Alt+f toggles it on every context; context.toggleDebugOverlay() does the same in code, and can be bound to any key a game prefers.

OPENGLCONTEXT_DISABLE_FPS_DISPLAY means what it always meant — no numbers over my screenshot — and now means it here: the overlay starts hidden. The key still brings it up, because a capture run is not the only thing that sets the variable.

FrameCounter measures the frame rate and does not draw it: the provider reads its number and the HUD puts it on screen. Drawing it from there would mean glOrtho/glPushAttrib, which mean nothing in a core profile.

Things pinned to the view

A first-person weapon is not HUD: it is geometry, it wants the scene’s lighting and it should be occluded by the world. What it shares with the HUD is that it must not move on screen. Put it on a transform, and write that transform’s pose from the camera in placeViewAttachments:

class Game(OverlayMixin, Context):
    def placeViewAttachments(self, pass_):
        "Called once the camera is settled, before any geometry is gathered."
        aim_at_camera(self.hand, self.getViewPlatform())

The timing is the whole feature. The render pass calls this from __call__, after the view platform is settled and before the scene is gathered — the only moment both are true. Posed from an idle callback or an event handler instead, the transform carries the previous frame’s camera: the weapon hangs back as the player walks and then slides forward to catch up, which is the most distracting thing a held model can do.

Written there it is pinned exactly, not approximately. The renderer draws a node with modelview = view × model. A node placed at the camera’s own pose carries model = cameraPose × local, and the view matrix is that pose inverted — so the two cancel and what is left is local, whatever the camera is doing. The object is fixed in view space by construction rather than by keeping up.

The hook is optional and costs nothing: a context that does not define it is never asked.

Colours

A HUD is read at a glance over a world whose colours nobody controls, so its skin fields are brighter and more opaque than the panel ones. They live on the same Skin node:

FieldWhat it colours
hudText, hudFill HUD text and the plate behind it.
hudGood, hudWarn, hudCritical A meter reading normally, low, and about to matter.
hudTrack Behind the filled part of a meter, so an empty bar is still a bar.
crosshair, crosshairHit The reticule, and the mark that says a shot connected.
debugFill, debugTitle, debugLabel, debugValue The developer overlay. Deliberately unlike the HUD's colours — the two must never be mistaken for each other.
hudPadding, hudSpacing Pixels inside a HUD element, and between one and the next.

Where things are

ModuleWhat is in it
ui.hudwidgets HUDLayer, HUDGroup, Crosshair, BarMeter, Readout, TextBlock, MessageQueue, and place().
ui.debugoverlay The overlay, the provider registry, value formatting and the providers shipped with it.
ui.screen The context mix-in: hudLayers, debugOverlay, and the one drawing hook both kinds of layer go through.
ui.widgets RootWidget, which a HUD layer and a panel are both kinds of: the children, the skin and the scaled copy of it.
stalltrace The recording: a stack sampler gated on the stall itself, the episode journal it writes, and the report that reads the file back.
looptrace LoopTrace: wall-clock cost of a whole loop iteration, divided among named phases, with the worst and the stall count a median throws away.
framecounter FrameCounter: how long the frames inside OnDraw took. Measures; does not draw.
passes.renderstats What one frame cost, counted where the pass knows the answer.
passes._flat placeViewAttachments: the one window in a frame where something can be pinned to the camera.

The design and its reasoning are in plans/HUD-DEBUG-OVERLAY.md.