-
-
Notifications
You must be signed in to change notification settings - Fork 2
GL Rendering Path
This page documents Gondwana's GPU/OpenGL rendering path from the engine cycle,
through RenderSurfaceHost, to the WinForms and Avalonia GL presentation
surfaces.
The GL path is used whenever:
surface.Backbuffer.IsGlThreadRendered == trueGpuBackbuffer returns true for this property.
Unlike bitmap rendering, GPU rendering is not executed directly by the engine thread. The engine requests a platform repaint, and the platform's GL callback performs rendering while its OpenGL context is current.
An initialized GpuBackbuffer owns an Skia SKSurface associated with a
GRContext. OpenGL operations must occur on a thread where the corresponding
platform GL context is current.
The engine cycle runs on a background engine thread. The WinForms or Avalonia GL callback is the reliable point at which the UI framework has made the correct context current.
Therefore:
- the engine controls when a frame is requested;
- the platform GL callback controls when GPU rendering is legal; and
- scene rendering and presentation both occur synchronously inside that callback.
flowchart TD
A["Engine thread"] --> B["Update game and drawing state"]
B --> C["Raise AfterFrameRender"]
C --> D["Post repaint request"]
D --> E["UI / GL thread"]
E --> F["Platform makes GL context current"]
F --> G["Render complete scene frame"]
G --> H["Blit GPU image to window surface"]
Engine.Cycle() performs background work and applies the configured foreground
frame-rate throttle. When foreground work is due, it calls
DoForegroundTasks(tick).
flowchart TD
A["Engine.Cycle()"] --> B["DoBackgroundTasks(tick)"]
B --> C{"Foreground interval elapsed?"}
C -- No --> D["No frame request"]
C -- Yes --> E["DoForegroundTasks(tick)"]
E --> F["AfterFrameRender event"]
F --> G["GPU adapter repaint handler"]
Within DoForegroundTasks:
-
BeforeFrameRenderis raised. -
DirectDrawingManager.UpdateAll(tick)updates drawing state. - GPU hosts are skipped by the engine's direct rendering loop.
- GPU hosts are also skipped by the direct presentation loop.
-
AfterFrameRenderis raised.
The relevant guards are:
if (!surface.Backbuffer.IsGlThreadRendered)
surface.RenderToBackbuffer(tick);and:
if (!surface.Backbuffer.IsGlThreadRendered)
surface.PresentBackbufferToAdapter();Thus, AfterFrameRender does not mean the GL frame has already been rendered.
For GPU hosts, it is the signal that foreground state preparation is complete
and a GL repaint may now be requested.
flowchart TD
A["DoForegroundTasks(tick)"] --> B["Update DirectDrawings"]
B --> C["Skip GPU host rendering"]
C --> D["Skip GPU host presentation"]
D --> E["AfterFrameRender event"]
E --> F["Adapter handler"]
F --> G["UiDispatcher.Post(repaint request)"]
G --> H["UI framework schedules GL callback"]
The repaint request is asynchronous. The engine cycle does not wait for the GL frame to finish.
WinFormGpuRenderSurfaceControl creates:
- a
WinFormGpuRenderSurfaceAdapter; - a
RenderSurfaceHost<GpuBackbuffer>; and - the event wiring that initializes or resizes the GPU backbuffer while the GL context is current.
It then calls:
adapter.SetHost(Host);SetHost subscribes to Engine.AfterFrameRender. The handler coalesces repaint
requests with _pendingInvalidate and posts SKGLControl.Invalidate() to the
UI thread.
Coalescing prevents a fast engine loop from flooding the WinForms message queue when the UI or GPU cannot paint at the same rate.
flowchart TD
A["Engine.AfterFrameRender"] --> B{"Invalidate already pending?"}
B -- Yes --> C["Drop duplicate request"]
B -- No --> D["Mark invalidate pending"]
D --> E["UiDispatcher.Post"]
E --> F["SKGLControl.Invalidate()"]
F --> G["SKGLControl.PaintSurface"]
WinForms invokes OnPaintSurface with its GL context current:
flowchart TD
A["OnPaintSurface"] --> B["Clear pending-invalidate flag"]
B --> C["Synchronize VSync setting"]
C --> D["Acquire GRContext"]
D --> E{"First usable context?"}
E -- Yes --> F["Initialize GpuBackbuffer"]
E -- No --> G{"Resize pending?"}
G -- Yes --> H["Reinitialize GPU resources"]
G -- No --> I["Continue"]
F --> I
H --> I
I --> J["Host.GlRenderAndSnapshot()"]
J --> K["Draw snapshot to window surface"]
K --> L["Flush GRContext"]
L --> M["Record completed GPU frame"]
The adapter draws the returned image across the entire backend render target.
Because the off-screen backbuffer and the control surface share the same
GRContext, this is a GPU-to-GPU blit rather than a CPU pixel readback.
AvaloniaGpuRenderSurfaceControl derives from OpenGlControlBase. Its adapter
subscribes to Engine.AfterFrameRender and posts
RequestNextFrameRendering() to the UI thread.
flowchart TD
A["Engine.AfterFrameRender"] --> B["Avalonia adapter handler"]
B --> C["UiDispatcher.Post"]
C --> D["RequestNextFrameRendering()"]
D --> E["Avalonia.OnOpenGlRender()"]
OnOpenGlInit creates the Skia GRContext while Avalonia's context is current
and initializes the GpuBackbuffer.
For each OnOpenGlRender callback:
- Reset Skia's cached GL state because Avalonia's compositor may have changed it.
- Calculate the physical-pixel dimensions.
- Reinitialize the GPU backbuffer if the control was resized.
- Wrap Avalonia's framebuffer in an Skia
SKSurface. - Call
Host.GlRenderAndSnapshot(). - Draw the returned image over the complete framebuffer.
- Flush the
GRContext. - Record the completed GPU frame.
Both platform paths converge here:
public SKImage? GlRenderAndSnapshot()
{
if (!Backbuffer.IsGlThreadRendered)
return null;
var tick = HighResTimer.GetCurrentTick();
RenderToBackbuffer(tick);
Backbuffer.EndFrame();
var img = Backbuffer.Snapshot();
Backbuffer.BeginFrame();
return img;
}The method obtains a fresh tick at actual GL render time. This is intentionally different from the earlier engine-cycle tick because the UI framework may deliver the repaint callback later.
The returned SKImage is GPU-backed and aliases GPU resources owned by the
backbuffer. The caller disposes it inside the same GL callback, before the next
frame can reuse those resources.
flowchart TD
A["GlRenderAndSnapshot()"] --> B{"GL-thread backbuffer?"}
B -- No --> C["Return null"]
B -- Yes --> D["Get current high-resolution tick"]
D --> E["RenderToBackbuffer(glTick)"]
E --> F["Backbuffer.EndFrame()"]
F --> G["Backbuffer.Snapshot()"]
G --> H["Backbuffer.BeginFrame()"]
H --> I["Return GPU-backed SKImage"]
The common method raises host rendering events and selects the GL implementation:
RenderBackbufferBegin?.Invoke();
if (Backbuffer.IsGlThreadRendered)
RenderToBackbufferGpuFull(tick);
else
RenderToBackbufferBitmap(tick);
RenderBackbufferEnd?.Invoke();Because this call originates inside the platform GL callback, the
RenderBackbufferBegin, post-scene hooks, and RenderBackbufferEnd callbacks
also run on the GL/UI thread for a GPU host.
Subscribers that issue GPU drawing commands may therefore safely use the backbuffer canvas during these callbacks, provided they do not retain it beyond the callback.
The GL renderer bypasses dirty-region processing entirely. Every GL frame redraws every configured view in full.
flowchart TD
A["RenderToBackbufferGpuFull(tick)"] --> B{"Any Views?"}
B -- No --> C["Clear entire backbuffer"]
C --> D["Clear FullRefreshNeeded"]
D --> Z["Return"]
B -- Yes --> E["For each View"]
E --> F["RenderContext.Push(view, tick)"]
F --> G["Get view overlays"]
G --> H["Clip to full View viewport"]
H --> I["Exclude higher-Z View overlaps"]
I --> J["Clear full View viewport"]
J --> K["For each visible SceneLayer"]
K --> L["Convert viewport to layer world extent"]
L --> M["Expand extent by one tile"]
M --> N["Get all visible drawables"]
N --> O["Draw clipped to full viewport"]
O --> P{"More layers?"}
P -- Yes --> K
P -- No --> Q["Draw all View overlays"]
Q --> R["Restore canvas"]
R --> S["RenderContext.Pop()"]
S --> T{"More Views?"}
T -- Yes --> E
T -- No --> U["Clear FullRefreshNeeded"]
U --> V["InvokePostSceneCanvasHooks()"]
For every visible layer, the renderer:
- Converts the complete viewport from screen space into that layer's world space.
- Expands the world rectangle by one tile in each direction to protect against rounding and boundary conditions.
- Retrieves every drawable intersecting that extent.
- Draws the result clipped to the complete viewport.
This is full-view rendering, not dirty-rectangle rendering.
When multiple views exist, higher-Z view rectangles are excluded from lower views. This is view composition and does not represent dirty-region optimization.
The following bitmap-only operations are never reached by the GL branch:
-
EnqueueFullSceneRefresh; -
CollectDirtyScreenArea; -
PreclearScreenAreas; -
RenderLayerDirtyRegions; and - clearing consumed layer
RefreshQueueinstances.
In addition, BackbufferBase.AddToBackbufferDirtyRectangle immediately returns
for a GL-thread-rendered backbuffer:
if (IsGlThreadRendered || area.IsEmpty)
return;Calls made internally by ClearRect and DrawDrawables therefore do not build
a presentation dirty rectangle for a GPU host.
The viewport clipping used by the GL renderer is not dirty clipping. It limits one view to its configured screen rectangle and preserves correct overlap between multiple views.
After scene rendering:
-
Backbuffer.EndFrame()finalizes the off-screen GPU drawing pass. -
GpuBackbuffer.Snapshot()returns a lightweight GPU-backed image. -
Backbuffer.BeginFrame()prepares the off-screen surface for continued use. - The platform callback draws the snapshot across its complete target surface.
- The platform flushes its
GRContext. - The snapshot is disposed before leaving the callback.
RenderSurfaceHost.PresentBackbufferToAdapter() is never used for a GPU host.
The GPU adapter's Present implementation is therefore only a defensive
fallback that disposes an unexpected image.
EngineConfiguration.TargetFPS controls how frequently
DoForegroundTasks() reaches AfterFrameRender, and therefore how frequently
the engine requests a GL repaint.
Actual GPU presentation may be further constrained by:
- the UI framework's repaint scheduling;
- monitor refresh rate;
- VSync;
- compositor behavior; and
- GPU workload.
WinForms coalesces pending invalidations, so engine cycles cannot accumulate an unbounded queue of repaint messages. Avalonia delegates repaint scheduling and composition to its rendering system.
GpuBackbuffer.RecordFrame() counts frames that actually completed their GL
callback. This lets Gondwana distinguish requested foreground cycles from
frames that were truly rendered.
| Operation | Thread |
|---|---|
| Background simulation and input polling | Engine thread |
DirectDrawingManager.UpdateAll |
Engine thread |
AfterFrameRender notification |
Engine thread |
| Repaint request posting | Engine thread to UI dispatcher |
WinForms PaintSurface
|
UI/GL thread |
Avalonia OnOpenGlRender
|
UI/GL thread |
GlRenderAndSnapshot |
UI/GL thread |
RenderToBackbufferGpuFull |
UI/GL thread |
| GPU snapshot and framebuffer blit | UI/GL thread |
RecordFrame |
UI/GL thread |
Because update work and GPU drawing occur on different threads, renderable state shared between them must either be synchronized or exposed to the render thread as a stable snapshot. The GL-context rule protects GPU resources; it does not by itself make arbitrary scene mutations thread-safe.
Engine.Cycle()
└─ DoForegroundTasks(engineTick)
└─ AfterFrameRender
└─ WinFormGpuRenderSurfaceAdapter handler
└─ UiDispatcher.Post(SKGLControl.Invalidate)
└─ SKGLControl.PaintSurface
└─ WinFormGpuRenderSurfaceAdapter.OnPaintSurface()
└─ RenderSurfaceHostBase.GlRenderAndSnapshot()
└─ RenderSurfaceHost.RenderToBackbuffer(glTick)
└─ RenderToBackbufferGpuFull(glTick)
Engine.Cycle()
└─ DoForegroundTasks(engineTick)
└─ AfterFrameRender
└─ AvaloniaGpuRenderSurfaceAdapter handler
└─ UiDispatcher.Post(RequestNextFrameRendering)
└─ AvaloniaGpuRenderSurfaceControl.OnOpenGlRender()
└─ RenderSurfaceHostBase.GlRenderAndSnapshot()
└─ RenderSurfaceHost.RenderToBackbuffer(glTick)
└─ RenderToBackbufferGpuFull(glTick)
The GL path is an engine-paced but platform-callback-driven full-view renderer:
- the engine updates state and requests a frame;
- the platform invokes rendering with the correct GL context current;
- all configured views and visible layers are redrawn;
- refresh queues and presentation dirty rectangles are bypassed;
- rendering, snapshotting, and presentation remain entirely on the GPU; and
- actual completed frames are counted independently of engine-cycle frequency.
This split is required by OpenGL context ownership and keeps GPU resource access inside the platform callback where it is valid.
- Home
- Make Your First Game in 30 Minutes
- Engine Architecture Overview
- Gondwana Engine Lifecycle
- Gondwana CLI Cheatsheet
- Assets Files
- Tilesheets
- Scenes and SceneLayers
- Sprites
- Views, Cameras, and Viewports
- DirectDrawing
- Game State Files
- Logging
- Movement and Controllers
- Input Handling
- Collision Detection
- Timers and Engine Timing
- Using the Effects System
- Engine Configuration