Host (SkiaViewer)
FS.Skia.UI.SkiaViewer is the framework's host: the part that turns a pure
Elmish/MVU program plus a declarative scene into pixels on a real
desktop window. It owns the operating-system window, the OpenGL context and the
window's default framebuffer, the SkiaSharp GPU context, and the frame loop that
drives all of them. Everything above it — your Model, Msg, update, and view
— is pure data and pure functions; everything inside SkiaViewer is the
side-effecting machinery that the rest of the framework deliberately keeps at arm's
length. Per ADR 0007, this package owns the host
outright: the OpenGL/Skia modules live here, not in a monolith, and SkiaViewer
depends only on
the split packages it needs (Scene, KeyboardInput) rather than pulling a
whole framework onto every consumer's transitive graph.
See the API reference for the full surface:
FS.Skia.UI.SkiaViewer,
Viewer, and the
reference index.
What the host does
The host has one job stated three ways:
-
Own the window and its event sources. It creates a Silk.NET window, wires
the load/update/render/resize/close callbacks, and attaches keyboard and mouse
input. Raw Silk.NET events become typed
ViewerEventvalues. -
Own the GPU pipeline. It brings up an OpenGL context on the window and a
SkiaSharp
GRContextbacked by that context, wraps the window's default framebuffer (FBO 0) as anSKSurface, then renders each frame straight onto it and presents via the windowing toolkit's buffer swap — no per-frame readback (feature 119; the readback-free direct present feature 118 deferred). -
Run the Elmish loop against those effects. It holds the current model,
dispatches messages through the application's
update, and interprets the resulting effects (RenderFrame,CaptureScreenshot,Shutdown, …) as real side effects.
The renderer path is intentionally narrow: there is no software fallback. If
OpenGL or Skia setup fails, the host returns a structured RenderDiagnostic
rather than silently degrading to another backend.
Two front doors
The package exposes the host through two layers, and it helps to know which one you are looking at.
The Elmish edge — FS.Skia.UI.SkiaViewer.Host.Viewer
This is the small, composable surface most apps use. A ViewerProgram<'model,'msg>
bundles the configuration plus your Init/Update/View and the mapper functions
that translate host events and app messages:
type ViewerProgram<'model, 'msg> =
{ Configuration: ViewerConfiguration
Init: unit -> 'model * Cmd<'msg>
Update: 'msg -> 'model -> 'model * Cmd<'msg>
View: 'model -> Scene
EventMapper: ViewerEvent -> 'msg option
EffectMapper: 'msg -> ViewerEffect<'msg> option
Subscriptions: 'model -> (string list * (Dispatch<'msg> -> IDisposable)) list }
You build one with Viewer.create, refine it with the withSubscription /
withEventMapping / withEffectMapping combinators, and start it with
Viewer.run, which returns Result<unit, RenderDiagnostic>. Viewer.run first
validates the configuration — non-empty title, positive size, positive frame rate,
a supported OS (Windows or Linux) — and only then hands off to the OpenGL host
body. This is the contract documented in
Viewer and the shape that
Elmish/MVU bindings target.
The package-level façade — FS.Skia.UI.SkiaViewer
The top-level SkiaViewer namespace adds
a much larger, evidence-and-lifecycle-oriented vocabulary: ViewerOptions,
ViewerLaunchOutcome, ScreenshotEvidenceResult, ViewerRunRequest/
ViewerRunEvidence, a GeneratedAppHost<'model,'msg> record, and pure
init/update state machines (ViewerModel/ViewerMsg, ViewerRunModel,
EvidenceWorkflowModel). These exist so that generated apps and the test/evidence
harness can drive and describe a viewer run — bounded smoke runs, first-frame
capture, screenshot evidence, desktop-session diagnostics — much of it as pure data
that can be asserted without ever opening a GPU surface.
Control and data flow
The runtime keeps pure application logic strictly separated from host side effects. The cycle, per frame, is:
|
Inside the host body (GlHost.run), dispatch is the hub: for a given
message it first consults the EffectMapper; if that yields a ViewerEffect, the
host interprets it directly, otherwise it runs the application's update, stores
the new model, and executes the returned Cmd<'msg>. View is called to produce
the Scene, and RenderFrame is the effect that actually paints it.
Rendering a frame
RenderFrame scene walks down through renderFrame → renderFrameDirect →
the shared SceneRenderer.paintNode painter:
-
Ensure the FBO-0 surface matches the current framebuffer pixel size — wrap the
window's default framebuffer (
GRGlFramebufferInfo→GRBackendRenderTarget→SKSurface,GRSurfaceOrigin.BottomLeft), recreating it (leak-free) on resize. -
Clear the surface to the configured clear color and draw every
Scenenode into its canvas via the single exhaustive painter shared with the screenshot path. - Flush Skia and the
GRContext, then call the windowing toolkit'sSwapBuffers.
The GL host renders directly onto the window's default framebuffer (FBO 0) and
presents via the buffer swap — no per-frame GPU→CPU readback, no staging buffer,
no command pool, no queue stall (feature 119, the readback-free direct present that
feature 118 deferred — the SKSurface-over-render-target wrap that returns null on
Vulkan, mono/SkiaSharp #1502, succeeds on GL). Screenshot/evidence capture is
decoupled (FR-004): the on-demand offscreen-readback routine renders its own
surface only when a capture is requested, so the steady-state live path never reads
back.
Frame timing
run does not lean on Silk.NET's own loop; it runs a manual while not closing &&
not shutdownRequested loop that calls DoEvents, then DoUpdate/DoRender gated
by a stopwatch against the target frame interval, with a 1 ms Thread.Sleep to
avoid a busy spin.
OpenGL startup, ownership, and shutdown
Bring-up is a fixed, ordered staircase, encoded as data in GlStartup.stages:
GL context → window surface → Skia GL GRContext → default-framebuffer (FBO 0)
wrap → Skia surface → Skia GPU context. Each stage maps to a stage-tagged
RenderDiagnostic, and the live setup is threaded through a Result-returning
sequence so that the first failure short-circuits with a precise, stage-tagged
diagnostic (GlContext, GlSurface, Framebuffer, SkiaContext).
Resource lifetime is modelled explicitly. GlResources is a pure
ownership ledger (acquire, transfer, releaseAll) that records each owned
handle, its category, and its release action; releaseAll releases in reverse
acquisition order. The companion GlStartup.simulateFailure /
simulateSuccessfulShutdown functions use this ledger to prove the reverse-order,
idempotent cleanup contract synthetically — without opening a real device — which
is how the host's teardown discipline is tested in environments with no GPU.
The live teardown mirrors that contract: run's finally block disposes
subscriptions and event mappings, then tears down the framebuffer SKSurface and
render target, the Skia GRContext, the GL interface, and the window in reverse,
each guarded so partial bring-up still unwinds cleanly.
Diagnostics and evidence
Failures are first-class values, not exceptions-as-control-flow. RenderDiagnostic
carries a Severity, a DiagnosticStage (e.g. GlSurface, SkiaContext,
FrameRender), a message, and an optional cause; the Diagnostics module provides
named constructors (startupFailed, frameRenderFailed, screenshotFailed, …).
The richer SkiaViewer-level evidence types then classify a whole run — blocked
stage, failure classification, visual-evidence artifacts — so the test harness can
distinguish an unsupported environment from a genuine product defect.
How it fits the rest of the framework
-
Above it: the Elmish/MVU runtime supplies the
Model/Msg/update/viewshape and the animation tick; the host is the thing that actually runs that program against a window. -
Beside it: the Scene package is the only drawing
vocabulary the host understands. Per
ADR 0008, there is a single
canonical
Scenetype — the host is retyped directly onto it, with no conversion shim — and the exhaustiveSceneRenderer.paintNodeis the single place those nodes become Skia draw calls. - Below it: Silk.NET (windowing + OpenGL bindings) and SkiaSharp (the GPU canvas) are the external dependencies; the host is the boundary that contains them so consumers never touch them directly.
Analysis
Implementation strengths
-
The full OpenGL bring-up sequence is threaded through a
Result-returningresult { … }computation expression, so a failure at any stage (createInstance,createSwapchain,createSkiaContext, …) short-circuits with a preciseRenderDiagnosticcarrying the exactDiagnosticStageand cause — there is no silent partial initialization. -
Teardown is genuinely careful:
run'sfinallyunwinds Skia context, swapchain, device, surface, instance, and window in reverse order, each step guarded by a non-zero-handle check, so a crash midway through bring-up still releases what was acquired. -
Resource cleanup discipline is independently testable:
GlResources/GlStartupmodel the acquire/release ledger as pure data and verify reverse-order, idempotent release synthetically, which is what lets cleanup be validated on machines with no GPU. -
The interactive frame path and the screenshot/evidence path both go through the
same
SceneRenderer.paintNode, so what you see on screen and what gets captured as evidence cannot drift apart.
Implementation weaknesses
-
Every frame renders Skia to an offscreen surface, reads the pixels back to a
managed
byte[], then re-uploads them through a Vulkan staging buffer and a one-time command buffer — a GPU→CPU→GPU round trip per frame that is convenient for screenshots but wasteful as a steady-state render strategy. -
The frame loop allocates and destroys a command pool, command buffer, fence, and
staging buffer per frame (
renderFrame/copyPixelsToSwapchainImage) rather than reusing pooled resources, which adds avoidable allocation and validation churn on the hot path. -
GlHost.runis a single very large function holding well over a dozenmutablelocals for the live handles; correctness depends on disciplined manual bookkeeping in that one scope rather than on the type system. -
The host swallows
QueueWaitIdleafter each frame upload (vkQueueWaitIdle), fully serializing CPU and GPU every frame — simple and correct, but it forfeits any overlap between frames.
Design pros
-
Owning the host in this package (ADR 0007) keeps the dependency graph honest:
consumers of the host pull in only
SceneandKeyboardInput, not an entire framework, which is the whole point of the split-package distribution. -
The pure/effectful boundary is sharp and well chosen: applications are pure
Model/Msg/update/view, and all side effects funnel through theViewerEffectinterpreter, so app logic is testable without a GPU and the host is the single auditable place where the world is touched. -
Modelling failure as data (
RenderDiagnosticwith stage + cause, then the richer run/evidence classifications) makes "why didn't it render?" answerable and lets tooling distinguish an unsupported environment from a real defect. - The deliberate no-fallback policy means behaviour is predictable: the host either renders with OpenGL/Skia or reports exactly why it could not, never quietly switching to a different, differently-behaving backend.
Design cons
-
The two front doors (
Host.Viewer's small Elmish edge versus the large package-levelSkiaViewerevidence/lifecycle vocabulary) present a big, partly overlapping surface; a newcomer must learn which layer to use for what, and the sheer number ofViewerOptions/ViewerModel/ViewerRunModel/evidence types is daunting. - Tying the host to OpenGL-only with no software path maximizes fidelity but narrows reach: there is no headless or non-OpenGL rendering route for environments where an OpenGL context is unavailable, only a diagnostic.
-
Restricting supported platforms to Windows and Linux (the
Viewer.runvalidation) is a clear, defensible scope choice but an explicit limitation for anyone targeting macOS. - The evidence/screenshot machinery is deeply woven into the host package; it serves this project's governance and proof-of-render needs well, but it is a lot of host-specific apparatus for a consumer who only wants to draw a window.
val string: value: 'T -> string
--------------------
type string = System.String