Architecture
This page explains the internal design decisions that make Thunder.MonoGame.Avalonia work. It is aimed at contributors and anyone debugging rendering or threading issues.
Why source compilation, not a NuGet binary reference
MonoGame ships a single MonoGame.Framework.DesktopGL package that hardcodes the
SDL2 windowing and run-loop path — there is no official Avalonia target, no extension
point for swapping the render loop, and no binary-compatible way to replace the
windowing layer. The only practical approach is to compile MonoGame source into the
output assembly directly, patching only the platform-specific partial classes that
SDL would otherwise own.
The library uses the libs/MonoGame git submodule as the source tree and a .csproj
glob to include the right files. The resulting assembly bundles both MonoGame and the
Avalonia bridge; consumers see a normal NuGet package and never reference MonoGame directly.
The render loop: Avalonia drives, MonoGame follows
The central conflict between the two frameworks is that both want to own the render thread:
- MonoGame calls
game.Run(), enters a blocking loop, and drives updates at a fixed rate viaPlatform.RunLoop(). - Avalonia calls
OpenGlControlBase.OnOpenGlRender()once per compositor frame on its dedicated render thread, expecting the callee to complete and return.
The resolution: MonoGameControl inherits OpenGlControlBase and overrides
OnOpenGlRender. Each time Avalonia calls it, the control calls game.Tick() — a single
Update + Draw cycle. MonoGame never enters its own loop; AvaloniaGamePlatform returns
GameRunBehavior.Asynchronous so that RunLoop() is never entered (it throws if called).
Avalonia compositor
│
└─► MonoGameControl.OnOpenGlRender()
│
├─ AvaloniaGlContext.SetForThread() (expose current FBO + GlInterface)
├─ AvaloniaGlStateScope ctor (save blend / depth / stencil / FBO / pixel-store)
├─ GraphicsDevice.InvalidateGLStateCache() (force MonoGame to re-apply its own state)
├─ game.Tick() (Update + Draw)
│ └─ Game.Update() → your Update()
│ └─ Game.Draw() → your Draw()
├─ sub-view delegates (SubViewGameControl instances)
├─ AvaloniaGlStateScope.Dispose() (restore Avalonia's GL state)
└─ AvaloniaGlContext.ClearForThread()
Deferred game start
game.Run() is not called in SetGame() or the property setter. It is called on the
first render frame — the earliest moment a live GL context is guaranteed. This ensures
LoadContent() can create textures, shaders, and GraphicsDevice resources without an
"no GL context" crash.
VSync and the compositor
Two MonoGame timing governors are disabled unconditionally before Run():
game.IsFixedTimeStep = false;
GraphicsDeviceManager.SynchronizeWithVerticalRetrace = false;
MonoGame's built-in 60 Hz governor uses Thread.Sleep to throttle; SynchronizeWithVerticalRetrace
calls SDL_GL_SwapBuffers. Both block the render thread mid-frame. Because Avalonia's
compositor already manages frame pacing and vertical sync for the whole window, either
governor would stall Avalonia's render thread and cause stuttering or deadlocks.
OpenGL context sharing
Avalonia owns the GL context. It creates the context, makes it current on the render thread,
calls OnOpenGlRender, then uses it for its own compositor work between frames. MonoGame
does not create or own a GL context; it issues GL calls against whichever context is current
on the thread.
**@Thunder.MonoGame.Avalonia.Platform.AvaloniaGlContext** is a thread-static broker that makes the current frame's GlInterface
and target framebuffer available to MonoGame's GraphicsDevice during OnOpenGlRender:
// At the top of OnOpenGlRender:
AvaloniaGlContext.SetForThread(gl, fb);
// MonoGame platform code consumes it:
var ctx = AvaloniaGlContext.Current; // throws outside OnOpenGlRender
gl.BindFramebuffer(GL_FRAMEBUFFER, ctx.Framebuffer);
The broker is cleared at the bottom of OnOpenGlRender. Accessing it outside that window
deliberately throws — it surfaces use-after-render bugs at the call site instead of
silently rendering to the wrong framebuffer.
Why MonoGame must target Avalonia's FBO, not framebuffer 0
Avalonia renders to an offscreen framebuffer object, not directly to the window's default
backbuffer (FBO 0). If MonoGame issues a Clear or DrawPrimitives against FBO 0, the
output is discarded — it never reaches Avalonia's compositor and the game surface appears
blank. AvaloniaGlContext.Framebuffer carries the FBO Avalonia wants this frame's rendering
to land in.
GL state isolation
Avalonia and MonoGame share one GL context and one set of GL state. Both leave state mutations behind that corrupt the other:
- Avalonia's font atlas uploader sets
GL_UNPACK_ROW_LENGTHto a non-zero value. MonoGame'sTexture2D.PlatformSetDatanever resets it, causing subsequent texture uploads to stride through memory at Avalonia's row width instead of the texture's own. - MonoGame leaves blend, depth, scissor, and cull-face enabled in configurations that
Avalonia does not expect when it resumes compositing after
OnOpenGlRenderreturns.
AvaloniaGlStateScope is an RAII struct that snapshots all critical state on entry and
restores it on Dispose. It also resets pixel-store parameters to GL defaults before
MonoGame renders so that texture uploads are never affected by Avalonia's atlas work.
The companion call GraphicsDevice.InvalidateGLStateCache() is the mirror: it discards
MonoGame's cache of what state it believes is current, so that MonoGame unconditionally
re-applies its own state at the start of each frame instead of skipping state changes it
thinks are already set.
Input pipeline
MonoGame's standard input providers (Keyboard, Mouse, GamePad) are backed by the
internal AvaloniaInputState singleton. The public read-only facade is AvaloniaInput.
Avalonia input event (KeyDown, PointerPressed, etc.)
│
└─► MonoGameControl.On{Key,Pointer}* override
│
└─► AvaloniaInputState.Set*(...) (internal — mutates key/mouse state)
game.Update()
│
└─► Keyboard.GetState() → reads AvaloniaInputState
└─► Mouse.GetState() → reads AvaloniaInputState
└─► GamePad.GetState() → reads AvaloniaInputState (populated by SdlControllerPump)
Gamepad events are polled separately: SdlControllerPump runs a background thread that
calls SDL_PollEvent for controller connect/disconnect and axis updates. The results are
written into AvaloniaInputState; AvaloniaGamePlatform.BeforeUpdate calls
SdlControllerPump.PumpEvents() once per game tick so that controller state is fresh
before each Update().
The currently-focused sub-viewport is identified by AvaloniaInput.FocusedViewportTag,
which holds the Tag of whichever SubViewGameControl most recently received focus.
Sub-view model
SubViewGameControl renders
a secondary viewport using the same GraphicsDevice as the main MonoGameControl. It
registers a draw delegate with the host control at attach time via RegisterSubView().
After each game.Tick(), MonoGameControl iterates its registered sub-view delegates and
invokes each with a SubViewportDrawContext
containing the sub-view's current physical pixel bounds. The game uses the context to set
GraphicsDevice.Viewport and call its own per-view draw logic.
Because all sub-views share the same GraphicsDevice, they cannot render in parallel —
the orchestration is sequential within a single OnOpenGlRender call.
Summary: what the library replaces in MonoGame
| MonoGame component | Replaced by |
|---|---|
| SDL2 window | MonoGameControl (an Avalonia OpenGlControlBase) |
Synchronous run-loop (RunLoop) |
OnOpenGlRender callback from Avalonia compositor |
| GL context creation | Avalonia's own GL context, accessed via AvaloniaGlContext |
| Input polling | Avalonia event overrides → AvaloniaInputState |
| Gamepad polling | SdlControllerPump background thread (SDL2, no window required) |
| Fixed-step governor | Disabled; Avalonia compositor drives frame pacing |
Everything else — SpriteBatch, BasicEffect, ContentManager, audio, RenderTarget2D,
the content pipeline — is unmodified MonoGame and works identically to the desktop GL target.