Skip to content

Support SkiaSharp Blazor views on Server and Hybrid hosts (#1194) - #4363

Draft
mattleibow wants to merge 12 commits into
mainfrom
mattleibow-blazor-server-hybrid-investigation
Draft

Support SkiaSharp Blazor views on Server and Hybrid hosts (#1194)#4363
mattleibow wants to merge 12 commits into
mainfrom
mattleibow-blazor-server-hybrid-investigation

Conversation

@mattleibow

@mattleibow mattleibow commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Extends SkiaSharp.Views.Blazor so the same SKCanvasView / SKGLView that run in Blazor WebAssembly also work under Blazor Server, Blazor Hybrid (BlazorWebView), static SSR, and Interactive Auto — closing the long-standing ask in #1194. Existing WebAssembly consumers are unaffected (API and behaviour identical; all new API is additive).

The components detect their host at runtime (RendererInfo, .NET 9+) and pick one of two present strategies:

  • Direct — WebAssembly draws natively in the browser (existing path, unchanged).
  • Bridged — Server/Hybrid/Static render on the .NET side, then transfer the frame to the browser where a small JS module paints it into the same <canvas>.

Server and Hybrid both marshal through IJSRuntime (SignalR vs in-process WebView), so one code path covers both.

Verified end-to-end

The Blazor Server sample was driven in a real browser (Playwright): all three pages render identically to the WebAssembly sample — CPU (SKCanvasView), GPU (SKGLView SkSL shader on a WebGL-backed canvas), and Drawing (pointer/wheel input) — with the full MudBlazor chrome, painting at cssSize × DPR. 22 unit tests pass; the library builds for net6.0/net9.0/net10.0.

What's included

  • Public API (additive): SKBlazorTransferFormat (Png/Jpeg/Put), SKBlazorOptions, AddSkiaSharpViewsBlazor(...), per-control TransferFormat/Quality. Configurable globally and per control, host defaults (Hybrid→Put, Server→Jpeg), orthogonal to the host so every path is testable from one host.
  • Internals: host detection + format resolution, a pure/testable frame producer (PNG/JPEG/raw RGBA), the host-agnostic IJSObjectReference bridge interop, and a backpressured bridged renderer (matches WASM EnableRenderLoop/Invalidate, awaits the previous frame, suppresses byte-identical frames, yields each loop iteration).
  • JavaScript: shared, pure-browser SKCanvasPresenter (Canvas2D + WebGL) and a thin host-agnostic SKHtmlCanvasBridge. The bridged path never imports the emscripten-coupled SKHtmlCanvas.js.
  • SKGLView keeps a WebGL-backed canvas under a bridged host (CPU raster server-side, presented via texImage2D; real server GPU is a future extension).
  • Tests: tests/SkiaSharp.Tests.Blazor (frame producer, host detection, DI).
  • Sample: samples/Basic/BlazorServer — a 1:1 copy of the WebAssembly sample, Server-hosted (shared pages/layout/styles are byte-identical; only the host wiring differs).
  • Docs: documentation/dev/blazor-server-hybrid-rendering.md — a normative, host-independent behaviour specification (WebAssembly + Server/Hybrid/Static/Auto) with a §14 conformance map, linked from AGENTS.md and the dev docs index.

Framework support

Server/Hybrid/Auto require .NET 9.0+ (uses the official RendererInfo API). net6.0/net8.0 remain WebAssembly-only and behaviourally unchanged.

Follow-ups

Tracked in #4367 — including partial (damage-based) frame transfer for the bridged path, a Blazor Hybrid sample + device test, the full test web-app pages + CI, native-asset packaging pack-verification, a static-SSR poster frame, folding the WASM paint onto the shared presenter, and API XML docs.

Closes #1194

Co-authored-by: Copilot App 223556219+Copilot@users.noreply.github.com

mattleibow and others added 5 commits July 7, 2026 20:59
Adds documentation/dev/blazor-server-hybrid-rendering.md describing the
architecture for supporting SkiaSharp Blazor views across all host models
(WebAssembly, Server, Hybrid, static SSR and Interactive Auto). Links it
from AGENTS.md Further Reading and the dev docs index.

Design for #1194.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds a bridged rendering path so SKCanvasView/SKGLView work outside Blazor
WebAssembly. The components now detect their host at runtime (RendererInfo,
net9+) and either draw directly in the browser (WebAssembly, unchanged) or
render on the .NET side and stream frames to the browser (Server, Hybrid,
static SSR).

New pieces:
- SKBlazorTransferFormat, SKBlazorOptions, AddSkiaSharpViewsBlazor (public API)
- Internal SKBlazorHost (host detection + transfer-format resolution),
  SKBlazorFrameProducer (pure, testable Png/Jpeg/raw payload producer),
  SKHtmlCanvasBridgeInterop (IJSObjectReference bridge), SKBlazorBridgedRenderer
  (backpressured render loop, identical-frame suppression)
- wwwroot/SKCanvasPresenter (shared, pure-browser paint primitives: Canvas2D +
  WebGL) and wwwroot/SKHtmlCanvasBridge (host-agnostic present module)

net6/net8 remain WebAssembly-only and behaviourally unchanged. All new API is
additive.

Design: documentation/dev/blazor-server-hybrid-rendering.md
Contributes to #1194

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds SkiaSharp.Tests.Blazor with 22 tests covering the frame producer
(PNG/JPEG signatures, raw RGBA layout, transparency, quality clamping),
host detection + transfer-format resolution precedence, and the DI
options registration. Uses InternalsVisibleTo to reach the internal
helpers.

Contributes to #1194

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…mple

The bridged renderer marked itself initialized only after the JS initialize()
call returned, but that call reports the initial size/DPI synchronously and the
OnMetricsChanged callback can race back before initialization completes, which
dropped the first (and only) render. Mark initialized before invoking the JS
initializer.

Adds samples/Basic/BlazorServer: a runnable Interactive Server web app that
renders SKCanvasView on the server and streams frames to the browser. Verified
end to end (canvas paints at cssSize x DPR and animates via the render loop).

Contributes to #1194

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds an implementation-status note to the design doc: Blazor Server rendering
is implemented and verified end to end; lists the delivered pieces and the
remaining follow-ups (Hybrid sample + device test, full test web app, packaging
pack-verification, static-SSR poster, WASM presenter fold-in, API docs).

Contributes to #1194

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

📦 Try the packages from this PR

Warning

Do not run these scripts without first reviewing the code in this PR.

Step 1 — Download the packages

bash / macOS / Linux:

curl -fsSL https://raw.githubusercontent.com/mono/SkiaSharp/main/scripts/get-skiasharp-pr.sh | bash -s -- 4363

PowerShell / Windows:

iex "& { $(irm https://raw.githubusercontent.com/mono/SkiaSharp/main/scripts/get-skiasharp-pr.ps1) } 4363"

Step 2 — Add the local NuGet source

dotnet nuget add source ~/.skiasharp/hives/pr-4363/packages --name skiasharp-pr-4363
More options
Option Description
--successful-only / -SuccessfulOnly Only use successful builds
--force / -Force Overwrite previously downloaded packages
--list / -List List available artifacts without downloading
--build-id ID / -BuildId ID Download from a specific build

Or download manually from Azure Pipelines — look for the nuget artifact on the build for this PR.

Remove the source when you're done:

dotnet nuget remove source skiasharp-pr-4363

Rewrites documentation/dev/blazor-server-hybrid-rendering.md as a normative,
host-independent specification of the whole system rather than a delta of what
changed. WebAssembly (Direct) behaviour is now specified in the absolute
alongside the Server/Hybrid/Static (Bridged) behaviour, covering the public
surface, host detection, sizing/DPI, both rendering strategies, the render
loop, transfer formats, the JavaScript modules, Auto and static SSR, framework
support and packaging. Adds a §14 conformance map linking every rule to the
code that implements it.

Contributes to #1194

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

📖 Documentation Preview

The documentation for this PR has been deployed and is available at:

🔗 View Staging Site
🔗 View Staging Docs
🔗 View Staging Gallery (Blazor)
🔗 View Staging Gallery (Uno Platform)
🔗 View Staging SkiaFiddle

This preview will be updated automatically when you push new commits to this PR.


This comment is automatically updated by the documentation staging workflow.

mattleibow and others added 2 commits July 7, 2026 22:45
…cher starvation

A continuous render loop (EnableRenderLoop) whose frames are byte-identical
hit the identical-frame suppression and returned before the awaited present,
so the loop iterated with no yield point. Over a momentarily static scene this
spun synchronously and starved the Blazor circuit dispatcher, preventing input,
metrics and even disposal from being processed (so the loop could not stop).

Yield to the host dispatcher every iteration so the loop cooperates and stays
cancellable regardless of frame suppression. Spec §7.5 updated to require this.

Found by an independent spec-conformance review.

Contributes to #1194

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…le 1:1

Replaces the minimal Server sample with a faithful copy of the Blazor
WebAssembly sample: the same MudBlazor layout, theme, and CPU/GPU/Drawing
pages, scoped CSS, font and helpers are byte-identical (verified via diff).
Only the host wiring differs for the WebAssembly->Server switch: Web SDK
csproj (no WebAssembly packages/props), Program.cs (AddInteractiveServerComponents
+ MudServices + app-root HttpClient), App.razor host document + Routes.razor
router, and _Imports without the WebAssembly.Http using.

Verified end to end in a browser: all three pages render identically via the
bridged path, including the SKGLView SkSL shader page on a WebGL-backed canvas.

Contributes to #1194

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
mattleibow and others added 4 commits July 8, 2026 00:10
…tection

Adds samples/Basic/BlazorHybrid: a .NET MAUI Blazor Hybrid app that mirrors the
WebAssembly and Server samples 1:1 (same MudBlazor layout, theme and CPU/GPU/
Drawing pages, byte-identical shared files) but runs as a mobile app via
BlazorWebView. Scaffolded from the maui-blazor template so the platform projects
are standard; only the host wiring differs. The font is a raw app-package asset
loaded via FileSystem.OpenAppPackageFileAsync (no HttpClient) - the only line
that differs from the shared Home.razor. Program.DefaultPage is a partial that
merges with the iOS/MacCatalyst entry points. Builds for Android and MacCatalyst
(ValidateXcodeVersion=false); MacCatalyst app launches and runs.

Hardens SKBlazorHost.Resolve so any interactive non-browser host that does not
report a recognized RendererInfo.Name is treated as Hybrid (bridged) - a native
WebView - rather than Unknown, so Blazor Hybrid renders regardless of the exact
name reported. Updates the spec (section 4) and the unit test.

Contributes to #1194

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Switches the Hybrid sample's start-page selection from a Program partial to
App.DefaultPage, matching the idiomatic MAUI approach used by the native
samples/Basic/Maui sample. Removes the Program.DefaultPage partial and reverts
the iOS/MacCatalyst platform Program classes to non-partial. The shared
MainLayout.razor now reads App.DefaultPage (the one line that differs from the
WebAssembly/Server samples).

Contributes to #1194

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…read

Blazor Hybrid was janky because it defaulted to raw-pixel transfer (Put), which
marshals a full-resolution buffer (~10 MB/frame) across the WebView bridge, and
because frame production ran on the dispatcher - which is the UI thread on
Hybrid (on Server the dispatcher is a threadpool thread, so it was smooth).

- Default every bridged host to JPEG (small payload). Put/Png remain opt-in.
- Produce the transfer payload on a background thread (Task.Run); backpressure
  keeps the frame buffer stable while encoding.

Contributes to #1194

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… from internals

Encapsulate the per-host 'if bridged else wasm' branching that lived in
SKCanvasView/SKGLView behind an internal IRenderer abstraction with three
implementations:

- CanvasDirectRenderer / GLDirectRenderer (Blazor WebAssembly, browser-only)
- BridgedRenderer (Blazor Server / Hybrid / static SSR)

The components now hold a single IRenderer, pick the right one in
OnAfterRenderAsync, and delegate Invalidate/Dispose/Dpi and parameter
forwarding through the interface. A unified RenderPaintHandler delegate lets
the bridged renderer own the SKGLView placeholder GL render-target.

Rename internals to drop the redundant SKBlazor prefix (SKBlazorHost -> Host,
SKBlazorHostKind -> HostKind, SKBlazorFrameProducer -> FrameProducer,
SKBlazorBridgedRenderer -> BridgedRenderer) and use file-scoped namespaces for
all new/updated internal and public-API files. Public API names keep the
SKBlazor prefix. Tests and the conformance map are updated to match.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mattleibow
mattleibow marked this pull request as draft July 8, 2026 07:59
@taublast

taublast commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Hey looks great!
Text below is suggestions on "what would be nice to have to consume this PR changes externally" (by DrawnUI and others..)

  1. Add public CanvasSize on SKCanvasView/SKGLView — this PR silently breaks reflection-based consumers.
    The previous implementation kept a canvasSize field on the view; this PR moves it into the private renderer (BridgedRenderer.canvasSize). DrawnUI (and likely other frameworks) today read it via typeof(SKCanvasView).GetField("canvasSize", NonPublic) because no public accessor exists — after this PR that returns null and layouts get SKSize.Empty with no compile error. Since IRenderer already tracks size, exposing public SKSize CanvasSize => renderer?.CanvasSize ?? SKSize.Empty (add it to IRenderer next to Dpi) is cheap and removes the reflection incentive permanently.

  2. Add a public size/DPI change notification on the views.
    BridgedRenderer.OnMetricsChanged receives size + DPR from JS but nothing reaches the component consumer; Dpi can only be polled. A retained-mode framework must re-measure/re-layout its tree exactly when metrics change. Ask: public event Action<SKSize, double>? MetricsChanged (or a protected virtual OnMetricsChanged) on both views, raised by all three renderers.

  3. Expose renderer kind publicly — consumers must be able to detect bridged mode.
    In bridged mode SKGLView is CPU raster with a placeholder GRBackendRenderTarget (framebuffer 0) and no real GRContext. A framework that manages GPU caches/image textures must branch on this, and today the only signal is inference from side effects. Ask: make HostKind public and add public bool IsBridged (or RendererKind { DirectRaster, DirectGL, Bridged }) on the views. One enum + one property, no behavior change.

  4. Expose GRContext on SKGLView (nullable).
    Same story as number 1: consumers currently reflect the private context field to share GPU resources (texture caches, cross-surface images). This PR restructures internals, so the reflection breaks. public GRContext? GRContext — null in bridged and pre-init — is the honest contract and pairs with number 3.


  1. Make IRenderer public and renderer creation overridable: protected virtual IRenderer? CreateRenderer(HostKind hostKind).
    Rendering strategy selection is hardcoded in OnAfterRenderAsync. Frameworks with retained scenes know exactly when content is dirty and what changed; the paint-callback + full-frame byte-compare (lastFrame.AsSpan().SequenceEqual) is redundant work for them — for a loop over a 1080p canvas that is a repaint plus an ~8 MB memcmp per iteration even when the framework knows nothing changed. A ss wrap or replace the strategy (e.g. supplyframes from its own invalidation pipeline) without forking. If making IRenderer public is too much API surface for
    this PR, unsealing BridgedRenderer + making ut please don't ship the interface shape asinternal sealed forever.

  2. Document (or fix) Invalidate() thread affinity.
    Invalidate() → _ = RenderLoopAsync() mutatesynchronization and walks straight into JSinterop. Frameworks drive invalidation from render loops/timers off the circuit dispatcher; calling this cross-thread
    is a data race today. Either document "must in the XML docs, or marshal internally(InvokeAsync) / use Interlocked on the flags. Cheap now, painful to diagnose later.

  3. Make Dispose virtual (or the views implement IAsyncDisposable).
    public void Dispose() is non-virtual and fireAsync(). Subclasses must shadow with new,which breaks disposal through the base type reference. protected virtual disposal hook is the standard component
    pattern; async disposal would also stop the

  4. Nice-to-have: poster frame at prerender/s
    Bridged presentation happens only after JS module init, so prerendered HTML shows an empty canvas. Optionally render
    the first frame server-side into an inline dnd so static SSR paints content immediately.Fine as a follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[FEATURE] Support SkiaSharp as a Blazor Extension

2 participants