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 via Platform.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_LENGTH to a non-zero value. MonoGame's Texture2D.PlatformSetData never 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 OnOpenGlRender returns.

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.

latest ▼