-
-
Notifications
You must be signed in to change notification settings - Fork 2
Custom Platform Adapters and Render Surfaces
Gondwana deliberately separates scene rendering from platform presentation.
That separation is what allows the same scene, views, sprites, direct drawings, widgets, and rendering rules to target very different environments:
- WinForms
- Avalonia
- Blazor/WebAssembly
- a custom desktop control
- an editor viewport
- an off-screen or remote presentation target
- a future platform that Gondwana does not ship with
The important part is choosing the smallest extension point that actually solves your problem.
Most custom platforms do not need a custom renderer.
If Gondwana can still render into an
SKCanvas, the usual extension point is a customRenderSurfaceAdapterBasepaired with the existingRenderSurfaceHost<TBackbuffer>.
This article covers both sides of that boundary:
- creating a custom platform/render-surface adapter
- integrating that surface into a custom platform host
- choosing between the built-in bitmap and GPU backbuffers
- adding custom drawing without replacing the renderer
- implementing a custom
BackbufferBasewhen the built-in backbuffers are not enough - understanding where Gondwana's current rendering abstraction stops

Figure 1 — Gondwana keeps scene rendering in the engine and platform presentation at the edge.
The major pieces are:
| Component | Responsibility |
|---|---|
Scene |
Owns world content: layers, tiles, sprites, direct drawings, and scene state. |
ViewManager |
Owns the views, cameras, and viewport rectangles rendered by a surface. |
RenderSurfaceHost<TBackbuffer> |
Coordinates scene rendering, dirty/full redraw behavior, resizing, backbuffer lifecycle, and presentation. |
BackbufferBase |
Provides the SKCanvas that Gondwana renders into and produces SKImage snapshots for presentation. |
RenderSurfaceAdapterBase |
Bridges Gondwana's rendered output to the platform's actual UI surface. |
| platform control/component | Owns native UI events, paint callbacks, layout, and usually input-event forwarding. |
GameHostBase |
Optional higher-level platform/application lifecycle integration: platform services, input, scene creation/binding, widget routing, engine startup, and shutdown. |
A useful mental model is:
The host renders. The backbuffer stores the rendered frame. The adapter presents it. The platform control owns the native surface.
Before creating a new rendering class, identify which problem you are solving.
| Goal | Recommended extension |
|---|---|
| Present Gondwana in a new UI framework or native control | Derive from RenderSurfaceAdapterBase and reuse a built-in backbuffer. |
| Wrap the adapter in a reusable platform control/component | Create a small platform control that owns the adapter and RenderSurfaceHost<TBackbuffer>. |
| Port the complete Gondwana application lifecycle to a new platform | Derive from GameHostBase and configure platform services/input around your render surface. |
| Add color grading, a vignette, debug overlay, scanlines, or other final-pass drawing | Use RenderBackbufferPostScene or IEnginePlugin.OnPostRenderCanvas. |
| Change how the in-memory render target is allocated, resized, snapshotted, or synchronized | Derive from BackbufferBase. |
| Replace SkiaSharp with a completely different renderer | This is a core renderer change, not merely a render-surface adapter. |
This distinction matters because RenderSurfaceHostBase is primarily engine infrastructure, not the normal external extension point.
The concrete RenderSurfaceHost<TBackbuffer> already contains Gondwana's rendering orchestration and is sealed. A custom platform should normally instantiate it, not replace it.
var adapter = new MyPlatformBitmapRenderSurfaceAdapter(nativeSurface);
var host =
new RenderSurfaceHost<BitmapBackbuffer>(adapter);
host.Bind(scene);That preserves the existing rendering behavior instead of duplicating it in every platform package.
A platform render adapter derives from:
public abstract class RenderSurfaceAdapterBaseIts public contract is intentionally small:
public int Width { get; protected set; }
public int Height { get; protected set; }
public event Action<RenderSurfaceAdapterResizedEventArgs>? Resized;
public abstract void Present(
SKImage bufferImage,
SKRectI bufferRect,
SKRect destRect);The base class also provides:
protected void SetDestinationSize(
int destWidth,
int destHeight);SetDestinationSize() updates the adapter dimensions and raises Resized when they actually change.
The render host listens for that event and coordinates the corresponding backbuffer resize and viewport scaling.
A custom adapter should normally do four things:
- Report the native surface size
- Raise Gondwana resize notifications
- Present an
SKImageto the platform - Own the platform-side lifetime of the presented image
It should generally not:
- traverse scene layers
- render sprites itself
- perform world-to-screen transforms
- manage cameras
- maintain refresh queues
- duplicate
RenderSurfaceHost<TBackbuffer>rendering logic
Those are engine responsibilities.
The bitmap path is the simplest custom-platform path and should usually be the first implementation attempted.
Use:
RenderSurfaceHost<BitmapBackbuffer>when the platform can accept raster output, a bitmap, a Skia image, a pixel buffer, or something that can be produced from one.
The existing WinForms, Avalonia, and Blazor bitmap integrations all follow this basic architecture even though their final presentation mechanisms are very different.

Figure 2 — Bitmap rendering occurs in the engine path; presentation is marshaled to the UI adapter.
At a high level:
- Gondwana renders dirty scene regions into the
BitmapBackbuffer. - The host finalizes the backbuffer frame.
- The backbuffer creates an immutable
SKImagesnapshot. - The host posts
RenderSurfaceAdapter.Present(...)through the engine UI dispatcher. - The adapter gives that snapshot to the native UI surface.
- The native surface paints the image.
- The adapter disposes the snapshot when it is no longer needed.
- The backbuffer begins the next frame.
By default, RenderSurfaceHost<TBackbuffer> uses dirty-rectangle presentation:
host.RedrawDirtyRectangleOnly = true;If your platform cannot efficiently repaint only part of a surface, disable it:
host.RedrawDirtyRectangleOnly = false;Gondwana will then present the entire backbuffer.
The following example uses a deliberately tiny fictional platform abstraction. The point is not the IPlatformBitmapSurface interface itself; the point is how a native platform surface maps onto RenderSurfaceAdapterBase.
using SkiaSharp;
public interface IPlatformBitmapSurface
{
int PixelWidth { get; }
int PixelHeight { get; }
bool IsAvailable { get; }
event Action? SizeChanged;
// Raised by the native platform when the surface should paint.
event Action<SKCanvas>? Paint;
// Requests a native repaint.
void Invalidate();
}A Gondwana adapter around that surface could look like this:
using Gondwana.Rendering;
using SkiaSharp;
public sealed class MyBitmapRenderSurfaceAdapter
: RenderSurfaceAdapterBase, IDisposable
{
private readonly IPlatformBitmapSurface _surface;
private SKImage? _currentImage;
private readonly Queue<SKImage> _disposeAfterPaint = new();
private SKRectI _sourceRect;
private SKRect _destinationRect;
private readonly SKPaint _paint = new()
{
BlendMode = SKBlendMode.Src,
FilterQuality = SKFilterQuality.None,
IsAntialias = false
};
public MyBitmapRenderSurfaceAdapter(
IPlatformBitmapSurface surface)
: base(
Math.Max(1, surface.PixelWidth),
Math.Max(1, surface.PixelHeight))
{
_surface = surface
?? throw new ArgumentNullException(nameof(surface));
_surface.SizeChanged += OnSizeChanged;
_surface.Paint += OnPaint;
}
private void OnSizeChanged()
{
var width = _surface.PixelWidth;
var height = _surface.PixelHeight;
// Do not report transient 0x0 layout/minimize sizes.
if (width <= 0 || height <= 0)
return;
SetDestinationSize(width, height);
}
public override void Present(
SKImage bufferImage,
SKRectI bufferRect,
SKRect destRect)
{
if (!_surface.IsAvailable)
{
bufferImage.Dispose();
return;
}
var old = _currentImage;
_currentImage = bufferImage;
_sourceRect = bufferRect;
_destinationRect = destRect;
if (old is not null &&
!ReferenceEquals(old, _currentImage))
{
_disposeAfterPaint.Enqueue(old);
}
_surface.Invalidate();
}
private void OnPaint(SKCanvas canvas)
{
var image = _currentImage;
if (image is null)
return;
var source = new SKRect(
_sourceRect.Left,
_sourceRect.Top,
_sourceRect.Right,
_sourceRect.Bottom);
canvas.DrawImage(
image,
source,
_destinationRect,
_paint);
while (_disposeAfterPaint.Count > 0)
_disposeAfterPaint.Dequeue().Dispose();
}
public void Dispose()
{
_surface.SizeChanged -= OnSizeChanged;
_surface.Paint -= OnPaint;
while (_disposeAfterPaint.Count > 0)
_disposeAfterPaint.Dequeue().Dispose();
_currentImage?.Dispose();
_currentImage = null;
_paint.Dispose();
}
}The platform-specific work is confined to:
- obtaining pixel dimensions
- receiving resize notifications
- requesting a repaint
- receiving a native paint callback
- drawing an
SKImageinto that native paint surface
Everything above that boundary remains standard Gondwana rendering.
This is one of the easiest mistakes to make in a custom adapter.
The host creates a snapshot and passes it to:
Present(
SKImage bufferImage,
SKRectI bufferRect,
SKRect destRect)The host does not immediately dispose that image after the call.
That is intentional because some platforms cannot draw it synchronously. A desktop adapter may need to retain it until the next native paint event. A browser adapter may need to read its pixels into a transfer buffer.
Therefore:
Once an image reaches
Present, the adapter must make sure it is eventually disposed.
Typical strategies are:
- draw it immediately, then dispose it
- retain it as the current frame, then dispose the previous frame after painting
- copy/read the data required by the platform, then dispose the image
If the native target has already been destroyed, dispose the image immediately.
The platform control owns the actual surface dimensions.
When those dimensions change, call:
SetDestinationSize(width, height);Do not manually resize the host or directly mutate backbuffer dimensions.
The normal chain is:
native control resized
↓
adapter.SetDestinationSize(...)
↓
RenderSurfaceAdapterBase.Resized
↓
RenderSurfaceHost<TBackbuffer>
↓
backbuffer resize request
↓
viewports scaled proportionally
↓
full scene refresh
For BitmapBackbuffer, the resize is requested from the UI side but the actual surface reallocation occurs safely during the next render-frame setup.
Many UI frameworks temporarily report 0x0 while:
- minimizing a window
- removing a control from layout
- creating a handle
- performing initial measurement
- switching tabs or docking layouts
Do not pass those transient values through SetDestinationSize().
Prefer:
if (width > 0 && height > 0)
SetDestinationSize(width, height);Also make sure the adapter has positive dimensions before constructing the render host.
RenderSurfaceHost<TBackbuffer> rejects adapters whose initial width or height is non-positive.
If a platform cannot know its real dimensions until after layout, either:
- delay creation of the host, or
- initialize the adapter with a harmless positive placeholder such as
1x1, then report the real size when available
Blazor uses this general delayed-size pattern.
A convenient platform package usually exposes more than a raw adapter.
It normally provides a native control/component that owns both:
- the platform adapter
- the Gondwana render host
Conceptually:
using Gondwana.Rendering;
using Gondwana.Rendering.Backbuffers;
public sealed class MyRenderSurface : IDisposable
{
public MyBitmapRenderSurfaceAdapter Adapter { get; }
public RenderSurfaceHost<BitmapBackbuffer> Host { get; }
public MyRenderSurface(IPlatformBitmapSurface nativeSurface)
{
Adapter =
new MyBitmapRenderSurfaceAdapter(nativeSurface);
Host =
new RenderSurfaceHost<BitmapBackbuffer>(Adapter);
}
public void Dispose()
{
Host.Dispose();
Adapter.Dispose();
}
}That produces a platform-facing API similar in spirit to Gondwana's built-in render-surface controls.
Application code can then interact with:
renderSurface.Host.Bind(scene);and with:
renderSurface.Host.ViewManagerwithout caring how the final pixels reach the screen.
Constructing a RenderSurfaceHost<TBackbuffer> also registers the host with Gondwana's RenderSurfaceHostRegistry.
That is how the engine discovers active render surfaces during its frame lifecycle.
You do not normally need a separate platform-side registration step:
var host =
new RenderSurfaceHost<BitmapBackbuffer>(adapter);Disposing the host unregisters it.
This is another reason to let the platform control or component clearly own the host lifetime.
A Scene can be bound to only one render-surface host at a time.
This is intentional ownership, not a platform limitation.
If a second host attempts to bind the same scene, Gondwana rejects the binding rather than allowing two hosts to race over the scene's render state and refresh queues.
For multiple simultaneous views of the same scene on one surface, use the host's:
host.ViewManagerThat is the intended mechanism for:
- split screen
- picture-in-picture
- minimaps
- multiple cameras
- overlapping viewports
Do not implement those cases by creating several hosts and binding the same Scene to all of them.
A GPU-backed render surface is not merely a faster version of the bitmap adapter.
It uses a different threading and presentation model.
Use:
RenderSurfaceHost<GpuBackbuffer>when the platform can provide a Skia-compatible GPU/GL context and a paint callback where that context is current.
A native GPU API by itself is not enough. If the platform cannot expose a compatible Skia GPU context and surface, start with the bitmap path instead.

Figure 3 — GPU rendering is driven from the native GL paint callback so rendering and presentation remain on the owning GL thread.
After GpuBackbuffer.Initialize(...) has attached the backbuffer to a GRContext, all work that touches that GPU surface must remain on the thread where that context is current.
That means the GPU adapter drives rendering from the native GL paint callback.
The engine's normal background bitmap-render path does not render a GpuBackbuffer.
Instead, the platform callback uses:
using var image =
host.GlRenderAndSnapshot();GlRenderAndSnapshot():
- renders the Gondwana scene
- ends the GPU backbuffer frame
- snapshots the GPU surface
- begins the next backbuffer frame
- returns the GPU-backed
SKImage
The adapter then draws that image to the native window surface.
The snapshot should be disposed inside the same paint callback.
Every GL-capable UI toolkit exposes its context differently, so the exact platform surface API will vary.
The Gondwana side typically follows this shape:
using Gondwana;
using Gondwana.Rendering;
using Gondwana.Rendering.Backbuffers;
using SkiaSharp;
public sealed class MyGpuRenderSurfaceAdapter
: RenderSurfaceAdapterBase, IDisposable
{
private RenderSurfaceHostBase? _host;
private GpuBackbuffer? _backbuffer;
private int _resizePending;
private int _invalidatePending;
public MyGpuRenderSurfaceAdapter(
int initialWidth,
int initialHeight)
: base(
Math.Max(1, initialWidth),
Math.Max(1, initialHeight))
{
}
public void SetHost(RenderSurfaceHostBase host)
{
_host = host
?? throw new ArgumentNullException(nameof(host));
_backbuffer = host.Backbuffer as GpuBackbuffer
?? throw new InvalidOperationException(
"This adapter requires GpuBackbuffer.");
Engine.Instance.AfterFrameRender += OnAfterFrameRender;
}
public void NativeSizeChanged(
int width,
int height)
{
if (width <= 0 || height <= 0)
return;
SetDestinationSize(width, height);
Interlocked.Exchange(
ref _resizePending,
1);
}
private void OnAfterFrameRender()
{
if (Interlocked.CompareExchange(
ref _invalidatePending,
1,
0) != 0)
{
return;
}
Engine.Instance.UiDispatcher?.Post(
RequestNativePaint);
}
// Call this from the platform's GL paint callback.
public void PaintOnGlThread(
GRContext grContext,
SKCanvas windowCanvas,
int framebufferWidth,
int framebufferHeight)
{
Interlocked.Exchange(
ref _invalidatePending,
0);
if (_backbuffer is null ||
_host is null)
{
windowCanvas.Clear(SKColors.Black);
return;
}
if (Interlocked.Exchange(
ref _resizePending,
0) == 1)
{
_backbuffer.Initialize(
grContext,
Width,
Height);
}
using var image =
_host.GlRenderAndSnapshot();
if (image is null)
{
windowCanvas.Clear(SKColors.Black);
return;
}
var destination = SKRect.Create(
0,
0,
framebufferWidth,
framebufferHeight);
windowCanvas.DrawImage(
image,
destination);
grContext.Flush();
_backbuffer.RecordFrame();
}
// The normal bitmap presentation path is not used by
// a GL-thread-driven adapter.
public override void Present(
SKImage bufferImage,
SKRectI bufferRect,
SKRect destRect)
{
bufferImage.Dispose();
}
private void RequestNativePaint()
{
// Platform-specific:
// invalidate/request the GL surface to paint.
}
public void Dispose()
{
Engine.Instance.AfterFrameRender -=
OnAfterFrameRender;
}
}This example omits platform-specific context acquisition, but the important Gondwana rules are present.
The host can create a GpuBackbuffer before the native GRContext exists.
That is expected.
The built-in GPU backbuffer begins with a temporary CPU raster surface. When the native GL context becomes available, initialize the real GPU target on the GL thread:
var adapter =
new MyGpuRenderSurfaceAdapter(width, height);
var host =
new RenderSurfaceHost<GpuBackbuffer>(adapter);
adapter.SetHost(host);
// Later, from the native GL thread:
var gpuBackbuffer =
(GpuBackbuffer)host.Backbuffer;
gpuBackbuffer.Initialize(
grContext,
adapter.Width,
adapter.Height);A production adapter normally performs that initialization automatically the first time its native GL paint callback receives a valid context.
On later resizes, call Initialize(...) again on the GL thread with the new dimensions.
For GpuBackbuffer, RequestResize() is intentionally a no-op. GPU resource recreation must happen where the GL context is valid.
GpuBackbuffer.IsGlThreadRendered returns true.
That changes host behavior.
The GPU path:
- does not consume the normal refresh queue
- does not accumulate a backbuffer dirty rectangle
- redraws the full viewport for each GL paint
- renders and presents synchronously on the GL thread
This is deliberate.
A dirty-region queue is useful when a CPU backbuffer is persistent and selected regions can be cheaply copied to a UI surface.
It is much less useful when the GL paint callback is already presenting a full GPU frame and the rendering context must remain on one thread.
Do not try to force the bitmap dirty-rectangle pipeline onto the GPU path.
For efficient GPU presentation, the backbuffer's GPU surface and the native presentation surface should participate in the same compatible Skia GPU context.
The built-in WinForms GPU implementation follows this pattern:
SKGLControl PaintSurface
↓
capture current GRContext
↓
GpuBackbuffer.Initialize(GRContext, width, height)
↓
Gondwana renders into off-screen GPU surface
↓
GlRenderAndSnapshot()
↓
draw GPU-backed SKImage to the window surface
That lets the final presentation remain a GPU-side operation instead of performing an expensive GPU-to-CPU readback every frame.
GpuBackbuffer stores GPU-related configuration such as:
gpuBackbuffer.VSync
gpuBackbuffer.MsaaSampleCount
gpuBackbuffer.TargetFpsThe backbuffer does not magically configure the native windowing toolkit.
A platform GPU adapter is responsible for translating relevant settings to its native GL control when that toolkit exposes them.
For example, if the native control exposes a VSync property, the adapter can observe:
gpuBackbuffer.VSyncand apply it to the platform control.
Similarly, the platform decides how a new native paint is requested.
The built-in WinForms GPU adapter listens to the engine's AfterFrameRender event and posts an invalidation to the UI thread. It also coalesces invalidation requests so a fast engine loop cannot flood the UI message queue.
That is a good pattern to copy when the target framework uses invalidation-driven painting.
A surprisingly large number of "custom renderer" requirements are actually post-scene drawing requirements.
Examples:
- vignette
- letterboxing
- CRT/scanline overlay
- debug information
- editor selection outlines
- fade-to-black
- whole-screen tint
- custom compositing
- a final Skia filter pass
For a single render surface, use:
host.RenderBackbufferPostScene += canvas =>
{
canvas.Save();
try
{
using var paint = new SKPaint
{
Color = new SKColor(
red: 0,
green: 0,
blue: 0,
alpha: 64)
};
canvas.DrawRect(
0,
0,
host.Backbuffer.Width,
host.Backbuffer.Height,
paint);
}
finally
{
canvas.Restore();
}
};This hook runs after the scene has been rendered but before the frame is finalized and presented.
The canvas is in full-surface screen space at this point.
Use:
RenderSurfaceHost<TBackbuffer>.RenderBackbufferPostScenewhen the effect belongs to one surface or one game instance.
Use:
IEnginePlugin.OnPostRenderCanvas(...)when the same behavior belongs to an engine-wide plugin.
The threading rule follows the backbuffer:
| Surface | Post-render hook thread |
|---|---|
| bitmap/CPU | engine render/background thread |
| GPU/GL | native GL thread while the GRContext is current |
Do not marshal a GPU canvas operation to another thread.
Also note that a CPU post-scene hook causes the host to mark the full backbuffer dirty, because Gondwana cannot know which pixels your arbitrary canvas code modified.
If you only need a localized world-space or view-space graphic, a normal DirectDrawing may be more efficient.
A custom adapter changes where the finished frame goes.
A custom backbuffer changes what Gondwana renders into.
That is a much deeper extension.
Consider a custom BackbufferBase when you need something such as:
- a specialized
SKSurfaceallocation strategy - unusual pixel formats
- a custom off-screen render target
- special synchronization around a shared native surface
- a nonstandard snapshot strategy
- a platform-specific GPU surface that still exposes an
SKCanvas - custom frame begin/end behavior
Do not create a custom backbuffer merely because you are adding a new UI framework.
The built-in BitmapBackbuffer and GpuBackbuffer are deliberately platform-neutral enough to be paired with new adapters.
A custom backbuffer must provide the core rendering primitives Gondwana expects.
Conceptually:
using System.Drawing;
using Gondwana.Drawing;
using Gondwana.Rendering.Backbuffers;
using SkiaSharp;
public sealed class MyBackbuffer : BackbufferBase
{
private SKSurface _surface;
// Important: RenderSurfaceHost<TBackbuffer> can use
// Activator.CreateInstance(type, width, height) for
// custom backbuffer types, so expose this constructor.
public MyBackbuffer(
int width,
int height)
: base(width, height)
{
_surface = CreateSurface(
width,
height);
}
public override SKCanvas Canvas =>
_surface.Canvas;
protected override void BeginFrame()
{
Canvas.RestoreToCount(1);
Canvas.Save();
Canvas.ResetMatrix();
Canvas.ClipRect(
new SKRect(
0,
0,
Width,
Height));
}
protected override void EndFrame()
{
_surface.Flush();
}
protected override SKImage Snapshot()
{
return _surface.Snapshot();
}
protected override void DrawTileFrame(
Tile tile,
RectangleF destRectScreen)
{
var image =
tile.CurrentFrame.SkImage;
if (image is null)
return;
Canvas.DrawImage(
image,
new SKRect(
destRectScreen.Left,
destRectScreen.Top,
destRectScreen.Right,
destRectScreen.Bottom));
}
protected override void RequestResize(
int width,
int height)
{
if (width <= 0 ||
height <= 0)
{
return;
}
_surface.Dispose();
_surface = CreateSurface(
width,
height);
UpdateSize(
width,
height);
}
private static SKSurface CreateSurface(
int width,
int height)
{
var info =
new SKImageInfo(
width,
height);
return SKSurface.Create(info)
?? throw new InvalidOperationException(
"Unable to create render surface.");
}
public override void Dispose()
{
_surface.Dispose();
base.Dispose();
}
}Because BackbufferBase members such as BeginFrame(), EndFrame(), and Snapshot() are declared protected internal in Gondwana, an override implemented from another assembly is normally declared protected override.
Then the standard host can use it:
var host =
new RenderSurfaceHost<MyBackbuffer>(adapter);RenderSurfaceHost<TBackbuffer> has explicit construction paths for Gondwana's built-in:
BitmapBackbufferGpuBackbuffer
For another backbuffer type it falls back to runtime construction with width and height.
Therefore a custom backbuffer intended for:
RenderSurfaceHost<MyBackbuffer>should expose:
public MyBackbuffer(
int width,
int height)If the target uses aggressive trimming or AOT compilation, remember that reflection-based construction may require preservation metadata or a Gondwana core integration for that custom type.
The built-in backbuffers have explicit construction paths partly to avoid that problem on browser/WASM targets.
BackbufferBase defaults to:
public virtual bool IsGlThreadRendered => false;When it remains false, Gondwana uses the normal CPU/bitmap-style engine path.
When it returns true, the host treats the backbuffer as a GL-thread-driven surface:
- the normal engine render loop skips it
- the adapter is expected to drive rendering
- full-frame GPU rendering is used
- dirty-rectangle presentation is bypassed
-
GlRenderAndSnapshot()becomes the expected presentation path
Do not override this property casually.
It is not a performance hint. It changes who owns the rendering thread.
Even with a custom BackbufferBase, Gondwana's current rendering contract is still Skia-based.
The backbuffer must expose:
SKCanvas Canvasand snapshots are represented as:
SKImageTiles, direct drawings, post-render hooks, and other rendering code ultimately target that Skia canvas abstraction.
Therefore a native renderer implemented directly in:
- Direct3D
- Vulkan
- Metal
- another non-Skia drawing API
cannot be dropped in solely by implementing RenderSurfaceAdapterBase.
You would need one of two things:
- a Skia surface/context backed by that native graphics API, while preserving the
SKCanvascontract, or - a deeper Gondwana renderer change that replaces the current Skia rendering abstraction
That boundary is intentional and worth preserving unless the engine itself is being redesigned.
A render-surface adapter is enough when you are embedding Gondwana into an application that already manages its own lifecycle.
If you are creating a first-class Gondwana platform package, rendering is only one part of the job.
GameHostBase provides the higher-level lifecycle seam.
Its initialization sequence includes:
Configure logging
↓
Configure platform
↓
Configure input
↓
Load content
↓
Create scene graph
↓
Bind scene
↓
Create scene objects
↓
Initialize engine
↓
Start engine
A minimal platform host might look like:
using Gondwana.Hosting;
public abstract class MyPlatformGameHost
: GameHostBase
{
protected MyRenderSurface RenderSurface
{
get;
}
protected MyPlatformGameHost(
MyRenderSurface renderSurface)
{
RenderSurface =
renderSurface
?? throw new ArgumentNullException(
nameof(renderSurface));
}
protected override void ConfigurePlatform()
{
// Register any platform-specific services here:
// audio formats, clipboard services, etc.
}
protected override void ConfigureKeyboard()
{
// Install the platform keyboard adapter/poller.
}
protected override void ConfigureMouse()
{
// Install the platform mouse adapter/poller.
}
protected override void ConfigureGamepads()
{
// Install the desired gamepad manager, if any.
}
protected override void ConfigureTouch()
{
// Install the platform touch adapter, if any.
}
protected override void BindScene()
{
if (Scene is null)
{
throw new InvalidOperationException(
"Scene has not been created.");
}
RenderSurface.Host.Bind(
Scene,
limitCameraToWorldBoundPx: false);
}
protected override void OnInputConfigured()
{
InitializeWidgetInput(
RenderSurface.Host);
}
}That follows the same broad separation used by Gondwana's platform hosting packages:
Gondwana.Hosting
↓
platform-specific GameHostBase derivative
↓
platform render-surface control/component
↓
RenderSurfaceAdapterBase
↓
RenderSurfaceHost<TBackbuffer>
GameHostBase.Initialize() ultimately starts the engine with a SynchronizationContext.
By default it uses:
SynchronizationContext.CurrentTherefore a platform host should usually initialize Gondwana from its UI thread after the platform has installed the appropriate synchronization context.
If the platform uses a different dispatch model, GameHostBase provides:
protected virtual SynchronizationContext? GetSynchronizationContext()and:
protected virtual void StartEngineCore(
SynchronizationContext syncContext)as advanced host-level seams.
The important invariant is that Gondwana's UI dispatcher must ultimately be able to marshal presentation work back to the platform thread that owns the native UI surface.
A full platform integration normally has two parallel responsibilities:
PLATFORM PACKAGE
│
┌──────────────┴──────────────┐
│ │
▼ ▼
Rendering integration Input integration
│ │
RenderSurfaceAdapterBase keyboard adapter
native paint/resize mouse adapter
control/component touch adapter
│ gamepad manager
└──────────────┬──────────────┘
▼
Gondwana
The render adapter should not become a grab bag for keyboard, mouse, touch, or gamepad logic.
The native render control may forward input events to the surrounding platform control or input adapter, but the rendering and input abstractions remain separate.
That keeps custom surfaces usable in editors, previews, tools, and applications that may want different input behavior.
For a new platform, build the integration in this order.
Implement:
RenderSurfaceAdapterBaseand pair it with:
RenderSurfaceHost<BitmapBackbuffer>First prove that:
- the adapter reports a valid size
- a scene renders
- resizing works
- full-frame presentation works
-
SKImageobjects are disposed correctly
At this stage, it is reasonable to disable dirty-region presentation:
host.RedrawDirtyRectangleOnly = false;Correctness first.
Once full-frame presentation is solid:
host.RedrawDirtyRectangleOnly = true;Make sure the adapter correctly respects:
bufferRect
destRectand that the platform can update only the affected destination region without leaving stale pixels behind.
Wire:
- keyboard
- mouse
- touch
- gamepad
using the appropriate Gondwana input abstractions.
If the platform supports Gondwana widgets, initialize:
InitializeWidgetInput(renderSurface.Host);after the render host and input pollers exist.
Once rendering and input work independently, wrap them in a platform host so startup, scene binding, engine lifecycle, and disposal are consistent.
Do not start with GPU unless the target requires it.
The bitmap implementation gives you a much simpler way to validate:
- scene correctness
- viewports
- resize behavior
- input coordinates
- platform lifecycle
Then the GPU adapter can be treated as a presentation/threading optimization rather than a simultaneous platform-porting and rendering problem.
Do not do this:
Present()
-> iterate scene
-> draw layers
-> draw sprites
Present() receives a frame Gondwana has already rendered.
It should present that frame.
Normally unnecessary.
Use:
RenderSurfaceHost<BitmapBackbuffer>or:
RenderSurfaceHost<GpuBackbuffer>and put platform behavior in the adapter/control/host package.
Every SKImage passed to the adapter must eventually be disposed.
Long-running rendering makes even a small per-frame leak very large very quickly.
Transient zero sizes can break resize assumptions and produce invalid surfaces.
Ignore them until the native target has a real positive size.
A resize event may occur on the UI thread, but GPU resource recreation must happen on the GL thread where the correct context is current.
Record the resize request, then perform:
GpuBackbuffer.Initialize(...)from the native GL callback.
Do not.
When using a GPU backbuffer, render and final Skia GPU presentation on the thread where the GRContext is current.
Use:
RenderBackbufferPostSceneor a DirectDrawing.
A backbuffer is infrastructure, not the first customization tool.
It does not.
The adapter changes the presentation edge.
Gondwana's renderer still targets SKCanvas.
A custom platform integration deserves tests at two levels.
Verify:
- initial dimensions are positive
- resize events update
WidthandHeight - duplicate sizes do not raise unnecessary resize notifications
- invalid/transient zero sizes are ignored by your platform wrapper
-
Present()disposes images when the target is unavailable - replaced frames are eventually disposed
- source/destination rectangles are honored
- disposal unsubscribes native events
Verify:
-
RenderSurfaceHost<TBackbuffer>can be constructed - a scene can be bound
- resize requests reach the backbuffer
- viewports scale as expected
- full redraw occurs after resize
- bitmap presentation occurs on the UI thread
- GPU rendering occurs on the GL thread
- GPU resize initialization occurs on the GL thread
- widgets receive input relative to the correct render surface
- disposing the platform control releases the host and adapter
For GPU integrations, also test repeated resize/minimize/restore cycles. Those are where context-lifetime and zero-size bugs tend to hide.
The built-in platform implementations are the best examples of the intended separation.
Gondwana/Rendering/RenderSurfaceAdapterBase.csGondwana/Rendering/RenderSurfaceHostBase.csGondwana/Rendering/RenderSurfaceHost.csGondwana/Rendering/Backbuffers/BackbufferBase.csGondwana/Rendering/Backbuffers/BitmapBackbuffer.csGondwana/Rendering/Backbuffers/GpuBackbuffer.cs
Bitmap:
Gondwana.WinForms/Rendering/WinFormBitmapRenderSurfaceAdapter.csGondwana.WinForms/Rendering/WinFormBitmapRenderSurfaceControl.cs
GPU:
Gondwana.WinForms/Rendering/WinFormGpuRenderSurfaceAdapter.csGondwana.WinForms/Rendering/WinFormGpuRenderSurfaceControl.cs
Hosting:
Gondwana.WinForms.Hosting/WinFormsGameHostBase.csGondwana.WinForms.Hosting/WinFormsBitmapGameHost.csGondwana.WinForms.Hosting/WinFormsGpuGameHost.cs
Gondwana.Avalonia/Rendering/AvaloniaBitmapRenderSurfaceAdapter.csGondwana.Avalonia/Rendering/AvaloniaBitmapRenderSurfaceControl.csGondwana.Avalonia/Rendering/AvaloniaGpuRenderSurfaceAdapter.csGondwana.Avalonia/Rendering/AvaloniaGpuRenderSurfaceControl.cs- platform hosting under
Gondwana.Avalonia.Hosting/
Gondwana.Blazor/Rendering/BlazorBitmapRenderSurfaceAdapter.csGondwana.Blazor/Rendering/BlazorBitmapRenderSurfaceComponent.razorGondwana.Blazor/Rendering/BlazorBitmapRenderSurfaceComponent.razor.cs- platform hosting under
Gondwana.Blazor.Hosting/
The three implementations are useful precisely because they solve presentation very differently while preserving the same engine-side model.
When adding a platform, keep this boundary in mind:
GAME / ENGINE CODE
Scene
Views
Sprites
DirectDrawings
Widgets
│
▼
RENDER ORCHESTRATION
RenderSurfaceHost<TBackbuffer>
│
▼
RENDER TARGET
BitmapBackbuffer
or
GpuBackbuffer
│
▼
PLATFORM BOUNDARY
RenderSurfaceAdapterBase
│
▼
NATIVE UI
Control / Window / Canvas / GL Surface
If the destination changes, customize the adapter.
If application lifecycle and input change, customize the platform host.
If the render-target storage model changes, customize the backbuffer.
If only final-frame drawing changes, use the post-render hook.
If SKCanvas itself is no longer an acceptable rendering abstraction, you have crossed the boundary into a core renderer change.
That separation is what lets Gondwana remain code-first and platform-agnostic without making every platform implementation reimplement the engine.
- 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