Logo FS-Skia-UI

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:

  1. 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 ViewerEvent values.
  2. Own the GPU pipeline. It brings up an OpenGL context on the window and a SkiaSharp GRContext backed by that context, wraps the window's default framebuffer (FBO 0) as an SKSurface, 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).
  3. 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:

Silk.NET window/input event
  -> ViewerEvent              (Loaded, UpdateTick, RenderTick, KeyDown/Up,
                               PointerMoved/Pressed/Released/Scrolled/Exited,
                               Resized, CloseRequested, DiagnosticReported)
  -> EventMapper              (ViewerEvent -> 'msg option)
  -> application Msg
  -> Update                   ('msg -> 'model -> 'model * Cmd<'msg>)
  -> Cmd<'msg>                (Elmish effects)
  -> EffectMapper             ('msg -> ViewerEffect<'msg> option)
  -> ViewerEffect             (RenderFrame, CaptureScreenshot, Shutdown,
                               ReportDiagnostic, Dispatch, InitializeRenderer)
  -> interpreter side effect  (draw / save PNG / close / log)

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 renderFramerenderFrameDirect → the shared SceneRenderer.paintNode painter:

  1. Ensure the FBO-0 surface matches the current framebuffer pixel size — wrap the window's default framebuffer (GRGlFramebufferInfoGRBackendRenderTargetSKSurface, GRSurfaceOrigin.BottomLeft), recreating it (leak-free) on resize.
  2. Clear the surface to the configured clear color and draw every Scene node into its canvas via the single exhaustive painter shared with the screenshot path.
  3. Flush Skia and the GRContext, then call the windowing toolkit's SwapBuffers.

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

Analysis

Implementation strengths

Implementation weaknesses

Design pros

Design cons

type ViewerProgram<'model,'msg> = { Configuration: obj Init: (unit -> 'model * obj) Update: ('msg -> 'model -> 'model * obj) View: ('model -> obj) EventMapper: (obj -> 'msg option) EffectMapper: ('msg -> obj) Subscriptions: ('model -> (string list * (obj -> obj)) list) }
type unit = Unit
'model
'msg
type 'T option = Option<'T>
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
type 'T list = List<'T>

Type something to start searching.