Logo FS-Skia-UI

Render Profiles Implementation Plan

Executive Summary

The SkiaViewer Vulkan host renders every frame to an offscreen GPU SKSurface, reads the pixels back into a managed CPU array (lastFrame), then re-uploads those pixels through a Vulkan staging buffer and presents. This GPU→CPU→GPU round trip is unconditional and is documented as a deliberate trade: it keeps screenshot/evidence capture trivially available at the cost of a full readback, a CPU→GPU re-upload, and a vkQueueWaitIdle stall on every frame.

This plan introduces two selectable render profiles:

  1. *ReadbackCapture* (development / debugging — current behaviour) — keeps the offscreen-render → readback → re-upload path so lastFrame always holds the most recent pixels and live-window screenshots are free.
  2. *DirectGpu* (release) — wraps the acquired swapchain image as a Skia surface, draws the scene directly into it, and presents with proper semaphore synchronisation. No per-frame readback, no staging copy, no queue-wait-idle stall. Live-window screenshots become an on-demand readback instead of a hot-path one.

The profile is a field on ViewerConfiguration, defaulted by build configuration (#if DEBUG) and overridable by environment variable, so released apps get the fast path automatically while development builds keep screenshots free.

Current Pipeline (verified)

All line numbers are against src/SkiaViewer/Host/Vulkan.fs at the time of writing.

Latent issue surfaced by this work

The current copy path writes into the swapchain image with vkCmdCopyBufferToImage at layout TransferDstOptimal, but the swapchain is created with ColorAttachmentBit only — it is missing TransferDstBit. This is technically a usage-flag violation (it appears to work because validation layers are off and the driver tolerates it). The ReadbackCapture profile should set ColorAttachmentBit | TransferDstBit and thereby fix this latent bug as a side effect.

What is not affected

The headless deterministic-evidence path (RendererMode strings such as pixel-readback, metadata-hash, deterministic-scene in Scene.fs and SkiaViewer.fs) does not go through the live VulkanHost present loop. CI evidence capture is therefore independent of the host render profile. The render profile only governs the live window present strategy and the live-window CaptureScreenshot effect. Note: RenderProfile (host present strategy) and RendererMode (evidence label) are orthogonal — do not conflate them.

Design

1. The profile type and configuration

Add to Host/Diagnostics.fs (and .fsi):

/// Host present strategy. Governs how a rendered frame reaches the swapchain.
type RenderProfile =
    /// Offscreen render → CPU readback → re-upload → present.
    /// Keeps `lastFrame` populated so live-window screenshots are free.
    /// Default for development/debug builds.
    | ReadbackCapture
    /// Draw directly into the acquired swapchain image and present with
    /// semaphore sync. No per-frame readback. Default for release builds.
    /// Live-window screenshots fall back to an on-demand readback.
    | DirectGpu

Extend ViewerConfiguration:

type ViewerConfiguration =
    { Title: string
      InitialSize: Size
      ClearColor: Color option
      TargetFrameRate: int option
      RenderProfile: RenderProfile
      Diagnostics: DiagnosticOptions }

RenderProfile is non-optional with an explicit default chosen in Viewer.defaultConfiguration (src/SkiaViewer/Host/Viewer.fs):

let private defaultRenderProfile =
    match Environment.GetEnvironmentVariable "FS_SKIA_RENDER_PROFILE" with
    | "readback" | "ReadbackCapture" -> ReadbackCapture
    | "gpu" | "direct" | "DirectGpu" -> DirectGpu
    | _ ->
#if DEBUG
        ReadbackCapture
#else
        DirectGpu
#endif

This gives the requested behaviour by default — debug builds get screenshots, release builds get the GPU-only fast path — while the env var allows a release build to be put into readback mode for field debugging and vice versa.

2. Swapchain usage flags per profile

createSwapchain (line 622) must select usage from the profile and the surface's capabilities.SupportedUsageFlags (already traced at line 636):

let imageUsage =
    match configuration.RenderProfile with
    | ReadbackCapture -> ImageUsageFlags.ColorAttachmentBit ||| ImageUsageFlags.TransferDstBit
    | DirectGpu       -> ImageUsageFlags.ColorAttachmentBit

Guard each required flag against capabilities.SupportedUsageFlags and emit a VulkanSwapchain diagnostic if unsupported (TransferDst is near-universal but not guaranteed). ColorAttachmentBit is mandated by spec for all swapchains, so DirectGpu is always satisfiable.

3. Frame dispatch by profile

Split renderFrame (line 1073) into the shared acquire prologue plus two present strategies. Keep the existing functions for ReadbackCapture; add a new path for DirectGpu.

let renderFrame configuration vk swapchainExt physicalDevice device
                swapchainState skiaState queueFamily scene =
    // ... acquire (see synchronisation note below) ...
    match configuration.RenderProfile with
    | ReadbackCapture ->
        // existing: renderSceneToPixels >>= copyPixelsToSwapchainImage,
        // returns a populated FrameSnapshot (Pixels = real bytes).
    | DirectGpu ->
        renderSceneDirect configuration vk skiaState swapchainState
                          image imageIndex colorType scene
        // returns a FrameSnapshot with Pixels = [||] (empty sentinel).

renderSceneDirect (new) does:

  1. Build a GRVkImageInfo describing the acquired swapchain VkImage (image handle, ImageTiling.Optimal, current layout, the swapchain Format, LevelCount = 1, sample count 1, the queue family). The swapchain image has no VkDeviceMemory/VkAlloc we own — SkiaSharp's GRVkImageInfo accepts the externally owned image; we pass Alloc as the default and let Skia manage layout transitions.
  2. use backendRT = new GRBackendRenderTarget(width, height, sampleCount=1, imageInfo).
  3. use surface = SKSurface.Create(skiaState.Context, backendRT, GRSurfaceOrigin.TopLeft, colorType). Null-check → FrameRender diagnostic.
  4. surface.Canvas.Clear clear; drawScene scene surface.Canvas.
  5. Flush with semaphores (see next section) so the present queue waits on render completion: surface.Flush(submitContext) / context.Flush(GRFlushInfo with signal semaphore) then context.Submit. Tell Skia the desired final layout is PresentSrcKhr via surface.Flush with a GRBackendSurfaceMutableState(PresentSrcKhr, queueFamily) so Skia inserts the transition for us — no manual barrier needed.
  6. vkQueuePresentKHR waiting on the render-finished semaphore.
  7. Return FrameSnapshot { Width; Height; ColorType; Pixels = [||] } — empty pixels signal "no readback available this frame".

4. Synchronisation

The current ReadbackCapture path is correct but coarse — it acquires with a fence and serialises with vkQueueWaitIdle. That is acceptable for the debug profile (correctness over throughput) and can be left as-is initially.

DirectGpu must not stall. Replace per-frame vkQueueWaitIdle with:

Skia owns the swapchain image's layout while it holds the backend render target, so the manual transitionBarrier calls in copyPixelsToSwapchainImage have no equivalent on this path — Skia emits the → PresentSrcKhr transition via the mutable-state flush.

5. Screenshots under DirectGpu

lastFrame will carry empty Pixels under DirectGpu, so CaptureScreenshot (line 1222) needs a fallback. Two-tier strategy in interpretEffect:

| CaptureScreenshot request ->
    match lastFrame with
    | Some snapshot when snapshot.Pixels.Length > 0 ->
        saveScreenshot request snapshot          // ReadbackCapture: free
    | _ ->
        // DirectGpu (or pre-first-frame): do an on-demand offscreen render.
        match renderOnDemandSnapshot () with     // reuse renderSceneToPixels on pendingScene
        | Ok snapshot -> saveScreenshot request snapshot
        | Error d -> ...queue or diagnostic as today

renderOnDemandSnapshot re-runs the existing renderSceneToPixels against the last pendingScene/scene for a single readback frame — paying the round trip only when a screenshot is actually requested instead of every frame. This preserves screenshot capability in release while keeping the steady-state hot path readback-free. (If on-demand readback in release is undesirable, the alternative is to return a clear "screenshots require the ReadbackCapture profile" diagnostic; recommend keeping on-demand so the capability never silently disappears.)

6. Diagnostics & observability

Files to change

File

Change

Surface impact

src/SkiaViewer/Host/Diagnostics.fs

Add RenderProfile DU; add field to ViewerConfiguration

public

src/SkiaViewer/Host/Diagnostics.fsi

Mirror the above

public .fsi — escalates

src/SkiaViewer/Host/Viewer.fs

Default profile (env + #if DEBUG) in defaultConfiguration

internal

src/SkiaViewer/Host/Viewer.fsi

Only if a withRenderProfile builder is exposed

public if added

src/SkiaViewer/Host/Vulkan.fs

Profile-aware createSwapchain usage; split renderFrame; add renderSceneDirect; semaphore sync; on-demand screenshot fallback

internal

src/SkiaViewer/Host/Vulkan.fsi

No change expected (only VulkanHost.run is public)

none

Routing & validation

Per AGENTS.md / CLAUDE.md, run ./fake.sh build -t Route first against the working-tree diff and run only the gates it prints. The Diagnostics.fsi change makes this a public `src//.fsi` change*, which Routing escalates to the maintainer-verify path. Expect to run the serialized six-target order:

  1. ./fake.sh build -t Dev
  2. ./fake.sh build -t GeneratedGuidanceCheck
  3. ./fake.sh build -t TemplateCheck
  4. ./fake.sh build -t GeneratedProductCheck
  5. ./fake.sh build -t EvidenceGraph
  6. ./fake.sh build -t EvidenceAudit

A new public type/field changes the per-package .fsi surface baseline for the SkiaViewer package — recapture via PerPackageSurface.captureCurrent (this is not regenerated by RefreshSurfaceBaselines; see the per-package-baseline note). Confirm with ./fake.sh build -t Route --enforce that no escalated evidence artifact is missing.

Testing

Risks & mitigations

Phased rollout

  1. Config plumbing — add RenderProfile, thread through, default it; keep both branches calling the existing readback path. No behaviour change. (Escalated .fsi change; run the six-target order.)
  2. Swapchain usage fix — profile-aware imageUsage; fixes the latent TransferDstBit violation for ReadbackCapture.
  3. *DirectGpu present path*renderSceneDirect + semaphore sync, behind the flag. Validate with layers on.
  4. On-demand screenshot fallback — wire CaptureScreenshot to the empty-pixel sentinel.
  5. Flip release default to DirectGpu and document.

Open questions

  1. On-demand readback vs. unsupported in release — recommend on-demand so the capability is never silently lost. Confirm acceptable.
  2. Frames-in-flight — start at 1 (already removes the stall) or go straight to 2–3 for smoother pacing? Recommend 1 first; pipeline depth is an independent follow-up.
  3. Profile namingReadbackCapture / DirectGpu proposed; could also be Debug / Release, but coupling names to build config is misleading once the env override exists. Recommend the behaviour-descriptive names.
type RenderProfile = | ReadbackCapture | DirectGpu
 Host present strategy. Governs how a rendered frame reaches the swapchain.
type ViewerConfiguration = { Title: string InitialSize: obj ClearColor: obj TargetFrameRate: int option RenderProfile: RenderProfile Diagnostics: obj }
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
type 'T option = Option<'T>
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

--------------------
type int = int32

--------------------
type int<'Measure> = int
val private defaultRenderProfile: RenderProfile
union case RenderProfile.ReadbackCapture: RenderProfile
 Offscreen render → CPU readback → re-upload → present.
 Keeps `lastFrame` populated so live-window screenshots are free.
 Default for development/debug builds.
union case RenderProfile.DirectGpu: RenderProfile
 Draw directly into the acquired swapchain image and present with
 semaphore sync. No per-frame readback. Default for release builds.
 Live-window screenshots fall back to an on-demand readback.
val imageUsage: obj
val renderFrame: configuration: ViewerConfiguration -> vk: 'a -> swapchainExt: 'b -> physicalDevice: 'c -> device: 'd -> swapchainState: 'e -> skiaState: 'f -> queueFamily: 'g -> scene: 'h -> 'i
val configuration: ViewerConfiguration
val vk: 'a
val swapchainExt: 'b
val physicalDevice: 'c
val device: 'd
val swapchainState: 'e
val skiaState: 'f
val queueFamily: 'g
val scene: 'h
ViewerConfiguration.RenderProfile: RenderProfile
union case Option.Some: Value: 'T -> Option<'T>
union case Result.Ok: ResultValue: 'T -> Result<'T,'TError>
union case Result.Error: ErrorValue: 'TError -> Result<'T,'TError>

Type something to start searching.