diff --git a/AGENTS.md b/AGENTS.md index 27432c99574..3497f03e12e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -415,4 +415,5 @@ Work directly for: | Error Handling | `documentation/dev/error-handling.md` | | Debugging | `documentation/dev/debugging-methodology.md` | | NuGet Packages | `documentation/dev/packages.md` | +| Blazor Server/Hybrid Rendering | `documentation/dev/blazor-server-hybrid-rendering.md` | | Release Notes & API Diffs | `documentation/dev/release-notes-and-api-diffs.md` | diff --git a/documentation/dev/README.md b/documentation/dev/README.md index 501f747e61a..588f1e02365 100644 --- a/documentation/dev/README.md +++ b/documentation/dev/README.md @@ -31,6 +31,7 @@ C# Wrapper (binding/SkiaSharp/) → P/Invoke → C API (externals/skia/src/c | [architecture.md](architecture.md) | Three layers, type mappings, call flow, threading | | [memory-management.md](memory-management.md) | Pointer types, ownership, lifecycle | | [error-handling.md](error-handling.md) | Error patterns across layers | +| [blazor-server-hybrid-rendering.md](blazor-server-hybrid-rendering.md) | Multi-host Blazor rendering: WASM/Server/Hybrid/Auto, present strategies, JS module structure | ### Contributing | Document | Description | diff --git a/documentation/dev/blazor-server-hybrid-rendering.md b/documentation/dev/blazor-server-hybrid-rendering.md new file mode 100644 index 00000000000..8d3970032ca --- /dev/null +++ b/documentation/dev/blazor-server-hybrid-rendering.md @@ -0,0 +1,444 @@ +# SkiaSharp Blazor Views — Behaviour Specification + +Component: `SkiaSharp.Views.Blazor` — `SKCanvasView`, `SKGLView` +Related issue: [#1194 — Support SkiaSharp as a Blazor Extension](https://github.com/mono/SkiaSharp/issues/1194) + +> This is a **normative specification** of how the SkiaSharp Blazor views behave across every +> Blazor hosting model. It describes the system as a whole and in the absolute — not as a set +> of changes relative to an earlier version. "MUST/SHOULD/MAY" are used in the RFC 2119 sense. +> §14 maps every normative rule to the code that implements it. + +--- + +## 1. Purpose and scope + +`SkiaSharp.Views.Blazor` provides two Razor components, `SKCanvasView` and `SKGLView`, that let +an application draw with SkiaSharp and have the result displayed in an HTML ``. The same +two components MUST function under every Blazor hosting model: + +- **Blazor WebAssembly** — the app's .NET code runs in the browser. +- **Blazor Server** — the app's .NET code runs on the server; the browser is a thin client + connected over a SignalR circuit. +- **Blazor Hybrid** — the app's .NET code runs in a native host process (`BlazorWebView` in + MAUI/WPF/WinForms) and the UI is a WebView. +- **Static server-side rendering (SSR)** — the component is prerendered on the server and is + not (yet) interactive. +- **Interactive Auto** — the component renders with Blazor Server on first visit and with + Blazor WebAssembly on subsequent visits. + +The public API and the drawing contract (§3) are identical across all hosts. What differs is +*where* drawing happens and *how* pixels reach the `` — this is captured by the two +**rendering strategies** in §6 and §7, selected per host in §4. + +## 2. Terminology + +- **Host** — the Blazor hosting model a component instance executes under. +- **Direct strategy** — SkiaSharp draws in the browser and writes pixels straight into the + `` (§6). Used only in WebAssembly. +- **Bridged strategy** — SkiaSharp draws on the .NET side; the frame is transferred to the + browser and a JavaScript module paints it into the `` (§7). Used for Server, Hybrid + and static SSR. +- **Present** — the act of making a rendered frame visible in the ``. +- **Frame** — one rendered image. +- **CSS size** — the layout size of the `` element in CSS pixels + (`clientWidth`/`clientHeight`). +- **Backing-store size** — the pixel grid the `` actually holds (`canvas.width` / + `canvas.height`). +- **DPR** — `window.devicePixelRatio`. + +## 3. Public surface and drawing contract + +### 3.1 Rendered element + +Each component renders exactly one element: + +```razor + +``` + +The component MUST splat `AdditionalAttributes` onto this `` so that applications can +attach standard Blazor attributes and event handlers (for example `style`, `class`, +`@onpointerdown`, `@onwheel`) uniformly across all hosts. + +### 3.2 Components and parameters + +`SKCanvasView` (CPU/raster surface) and `SKGLView` (GPU surface) share this parameter surface: + +| Member | Meaning | +|--------|---------| +| `OnPaintSurface` | Callback invoked to paint a frame. `SKCanvasView` supplies `SKPaintSurfaceEventArgs`; `SKGLView` supplies `SKPaintGLSurfaceEventArgs`. | +| `EnableRenderLoop` (bool) | When `true`, frames are produced continuously; when `false`, only on demand. Changing it calls `Invalidate()` (§8). | +| `IgnorePixelScaling` (bool) | Selects the coordinate space handed to `OnPaintSurface` (§5.3). Changing it calls `Invalidate()`. | +| `AdditionalAttributes` | Unmatched attributes, splatted onto the ``. | +| `Dpi` (double, get) | The device pixel ratio currently in effect (§5). | +| `Invalidate()` | Requests that a frame be rendered (§8). | +| `TransferFormat` (`SKBlazorTransferFormat?`) | Bridged-only frame encoding (§7.4). Ignored by the Direct strategy. | +| `Quality` (`int?`) | JPEG quality for the bridged JPEG format (§7.4). Ignored otherwise. | + +`TransferFormat` and `Quality` are available on .NET 9.0 and later (see §12). + +### 3.3 Options and dependency injection + +- `enum SKBlazorTransferFormat { Png, Jpeg, Put }` — the bridged transfer encodings (§7.4). +- `sealed class SKBlazorOptions { SKBlazorTransferFormat? TransferFormat; int Quality = 85; }` — + global defaults. +- `IServiceCollection AddSkiaSharpViewsBlazor(Action? configure = null)` — + registers global defaults. Calling it is OPTIONAL; when it is not called, `SKBlazorOptions` + defaults apply. + +All members in §3.2 and §3.3 are additive; no existing public signature changes across hosts. + +### 3.4 Failure and lifecycle behaviour + +- A component MUST NOT throw during first render on any host; it produces frames once it has a + positive size and DPR. +- `IDisposable.Dispose()` MUST release the render buffer and any host-specific resources + (JavaScript module reference, size/DPI observers, native GL objects) and MUST be safe when + the underlying transport (for example a Server circuit) is already gone. + +## 4. Execution location and strategy selection + +On first interactive render, a component determines its host and selects a strategy: + +| Detected host | `RendererInfo.Name` | Strategy | +|---------------|---------------------|----------| +| WebAssembly | `WebAssembly` | **Direct** (§6) | +| Blazor Server | `Server` | **Bridged** (§7) | +| Blazor Hybrid | `WebView` | **Bridged** (§7) | +| Static SSR | `Static` | **Bridged** (§7), non-interactive (§11) | + +Rules: + +1. On .NET 9.0+ the host MUST be resolved from `ComponentBase.RendererInfo.Name` + (`WebAssembly` → Direct; `Server`/`WebView`/`Static` → Bridged). +2. Host detection is only consulted once a component is interactive. An **unrecognized** + host name that is **not** the browser (for example a MAUI `BlazorWebView` that does not + report exactly `WebView`) MUST be treated as **Hybrid (Bridged)** — a native, interactive, + non-browser host is a WebView. Only a browser host (`OperatingSystem.IsBrowser()`) falls + back to WebAssembly (Direct). +3. On target frameworks earlier than .NET 9.0 (`RendererInfo` does not exist) the components are + WebAssembly-only (§12) and always use the Direct strategy; the bridged path is not compiled. +4. Because the browser is detected via `OperatingSystem.IsBrowser()`, a WebAssembly host ALWAYS + resolves to the Direct strategy even if `RendererInfo` is unavailable. + +## 5. Coordinate system, sizing and DPI (all hosts) + +These rules are identical for the Direct and Bridged strategies. + +### 5.1 Size and DPR sources + +- The **CSS size** is observed from the `` element's `clientWidth`/`clientHeight` and + is reported whenever it changes. +- The **DPR** is `window.devicePixelRatio`, sampled on a ~1 second interval (there is no DOM + event for DPR changes) and reported when it changes. +- A change to either the CSS size or the DPR MUST trigger `Invalidate()`. + +### 5.2 Backing-store resolution + +The rendered frame (and the `` backing store) MUST be sized to `cssWidth × dpr` by +`cssHeight × dpr` device pixels (each dimension truncated to a whole number of pixels), so +output is crisp on high-DPI displays. A component MUST NOT render while the CSS size or DPR is +non-positive. + +### 5.3 `IgnorePixelScaling` + +`OnPaintSurface` receives two `SKImageInfo` values: a *user-visible* info and a *raw* info. + +- When `IgnorePixelScaling` is `false` (default): both infos describe the device-pixel + backing store. The application draws in device pixels. +- When `IgnorePixelScaling` is `true`: the drawing canvas is pre-scaled by `dpr` (via + `canvas.Scale(dpr)` followed by `Save`), and the user-visible info describes the CSS-pixel + size, so the application can draw in CSS-logical units while output remains full-resolution. + +### 5.4 `Dpi` property + +`Dpi` MUST return the DPR currently in effect for the active strategy (the value most recently +reported by the DPI source). + +### 5.5 Input coordinate mapping + +Pointer events delivered by Blazor report `OffsetX`/`OffsetY` in CSS pixels. To map an input +point to the coordinate space a frame was drawn in, an application multiplies by +`canvas.width / canvas.clientWidth` (and the height equivalent); this single ratio folds in +both the DPR and any additional CSS scaling of the element. This mapping is identical for all +hosts and is the application's responsibility (the components expose raw events per §3.1). + +## 6. Direct strategy (WebAssembly) + +In WebAssembly the component owns an in-browser JavaScript module (`SKHtmlCanvas`, §9.2) via +`[JSImport]`/`IJSObjectReference`, keyed to the element id `"_bl_" + ElementReference.Id`, and +SkiaSharp draws using the browser's own WebAssembly `libSkiaSharp`. + +### 6.1 Render loop + +`Invalidate()` calls the module's `requestAnimationFrame(enableRenderLoop, deviceW, deviceH)`: + +- The module sets the `` backing-store size to `deviceW × deviceH`. +- It schedules a `requestAnimationFrame`; the callback invokes the .NET render callback, which + paints one frame (§6.2 / §6.3) and presents it. +- When `EnableRenderLoop` is `true`, the callback reschedules itself each frame, producing a + browser-paced (display-refresh) loop; when `false`, exactly one frame is produced per + `Invalidate()`. + +### 6.2 `SKCanvasView` (raster) + +- The surface uses `SKImageInfo.PlatformColorType` with `SKAlphaType.Opaque`. +- Pixels are rendered into a pinned managed buffer (reused across frames unless the size + changes) via `SKSurface.Create` over that buffer. +- If `IgnorePixelScaling` is `true`, the canvas is scaled by `dpr` before `OnPaintSurface`. +- Presenting calls the module's `putImageData`, which wraps the pinned buffer as a + `Uint8ClampedArray` over the emscripten heap and calls `context.putImageData` on a `2d` + context. + +### 6.3 `SKGLView` (GPU) + +- The module creates a WebGL context with attributes `{ alpha, depth, stencil: 8, antialias, + premultipliedAlpha, majorVersion: 2 }`, falling back from WebGL 2 to WebGL 1 if necessary, + and returns its framebuffer id, sample count, stencil count and depth. +- The component creates a `GRContext` over the GL interface, with a 256 MB resource-cache + limit, and renders with `SKColorType.Rgba8888` and `GRSurfaceOrigin.BottomLeft`. +- A `GRBackendRenderTarget` is built from the reported framebuffer id / samples / stencils and + is recreated whenever the size changes or it becomes invalid; the `SKSurface` is created over + it. +- `OnPaintSurface` receives `SKPaintGLSurfaceEventArgs` referencing the real render target. If + `IgnorePixelScaling` is `true`, the canvas is scaled by `dpr`. After painting, the canvas and + the `GRContext` are flushed. + +### 6.4 Sizing / DPI / lifecycle + +- Size is observed with a `ResizeObserver` on the `` (`SizeWatcher`, §9.4); DPR with a + ~1 second poll (`DpiWatcher`, §9.5). Both report to the component per §5.1. +- Disposal deinitialises the module, unsubscribes the watchers and frees the pinned buffer / + GL objects. + +## 7. Bridged strategy (Server, Hybrid, static SSR) + +Under a bridged host, SkiaSharp draws on the .NET side and the frame is transferred to the +browser through `IJSRuntime`. The **same** C# code serves Server and Hybrid; only the transport +differs (SignalR for Server, in-process for the Hybrid WebView). The browser side uses a +host-agnostic module (`SKHtmlCanvasBridge`, §9.3) that has no dependency on emscripten and MUST +NOT be the WebAssembly module of §6. + +### 7.1 Initialisation and metrics + +On first interactive render the component imports `SKHtmlCanvasBridge` and calls its +`initialize(canvas, dotNetRef, isGL)`, where `isGL` is `false` for `SKCanvasView` and `true` +for `SKGLView`. The module then, and whenever they change, reports the element's CSS size and +DPR back to .NET via `dotNetRef.invokeMethodAsync('OnMetricsChanged', width, height, dpr)`. A +metrics report updates the component's size/DPR (§5) and calls `Invalidate()`. + +A component MUST be ready to receive the first metrics callback before its initialisation call +returns (the first report is synchronous inside `initialize`); it MUST NOT drop that first +render. + +### 7.2 Frame production + +For each frame (once size and DPR are positive): + +1. Compute the device backing-store info per §5.2 with `SKColorType.Rgba8888` and + `SKAlphaType.Premul`, into a reused pinned buffer. +2. Create an `SKSurface` over the buffer; apply `IgnorePixelScaling` scaling per §5.3. +3. Invoke `OnPaintSurface` (`SKCanvasView` → `SKPaintSurfaceEventArgs`; `SKGLView` → §7.6). +4. Produce the transfer payload for the resolved format (§7.4). Payload production (encoding, or + the RGBA copy for `Put`) SHOULD run off the host dispatcher, because on Blazor Hybrid the + dispatcher is the UI thread; backpressure (§7.5) keeps the buffer stable while this happens. +5. **Suppress** the frame if its bytes are byte-identical to the previously transferred frame. +6. Otherwise transfer it via the module's `present(canvas, bytes, deviceW, deviceH, format, + isGL)`. + +### 7.3 Present backends (by view type) + +The browser `` MUST keep the context type matching the view: + +- `SKCanvasView` presents through a **2D** context: `putImageData` for raw pixels, or + `createImageBitmap(blob)` + `drawImage` for encoded frames. +- `SKGLView` presents through a **WebGL** context: the frame is uploaded as a texture and drawn + with a full-screen quad (from a raw buffer via `texImage2D`, or from a decoded `ImageBitmap`). + The presenter flips the texture vertically so a top-left-origin raster frame displays + upright. + +The module sets the `` backing-store size to the frame's device size before presenting. + +### 7.4 Transfer formats + +`SKBlazorTransferFormat` selects how a frame becomes bytes: + +| Format | Bytes | Browser present | Notes | +|--------|-------|-----------------|-------| +| `Png` | `SKImage.Encode(Png)` | decode → draw/texture | lossless, keeps alpha, larger | +| `Jpeg` | `SKImage.Encode(Jpeg, quality)` | decode → draw/texture | small; no alpha; quality clamped to 0–100 | +| `Put` | raw RGBA (unpremultiplied) | `putImageData` / `texImage2D` | no encode/decode; largest; bytes are already RGBA and directly usable | + +The effective format MUST be resolved with this precedence: + +1. the per-control `TransferFormat` parameter, else +2. the global `SKBlazorOptions.TransferFormat`, else +3. a host-independent default of `Jpeg`. + +`Jpeg` is the default for **every** bridged host, including Hybrid: the WebView bridge marshals +the payload across the native/JS boundary rather than sharing memory, so a small encoded frame is +much cheaper than a raw full-resolution buffer (`Put`) every frame. Apps can still opt into `Put` +(lossless, no encode) or `Png` (lossless with alpha) per control or globally. + +The effective JPEG quality is the per-control `Quality`, else `SKBlazorOptions.Quality` +(default `85`). + +The transfer format is independent of the host, so any host may be configured to use any +format (this makes every transfer path exercisable from a single host in tests). + +### 7.5 Render loop, invalidation and backpressure + +The loop semantics match §8. Additionally, a bridged component MUST apply backpressure: it MUST +NOT begin producing the next frame until the previous `present` transfer has completed, and it +MUST always draw the latest state rather than queueing frames. Concurrent `Invalidate()` calls +while a frame is in flight coalesce into a single subsequent frame. As a consequence, a slow +transport self-limits the effective frame rate without unbounded memory growth. There are no +framework-imposed frame-rate caps. + +The loop MUST yield to the host dispatcher on every iteration so the host remains responsive and +the loop stays cancellable — including when a frame is suppressed (§7.2 step 5) and therefore +performs no transfer that would otherwise provide a yield point. Without this a continuous render +loop over a momentarily static scene would spin synchronously and starve the dispatcher, +preventing input, metrics and even disposal from being processed. + +### 7.6 `SKGLView` under a bridged host + +Server-side drawing is CPU raster. `SKGLView` still keeps a WebGL-backed `` (§7.3) and +its frame is presented via the WebGL texture path. `OnPaintSurface` receives an +`SKPaintGLSurfaceEventArgs` whose surface is the raster surface and whose `GRBackendRenderTarget` +is a lightweight placeholder sized to the frame; applications draw through `Surface.Canvas` as +usual. Real GPU drawing on the .NET side (a headless `GRContext`) is a permitted future +extension that would feed the same present path unchanged. + +### 7.7 Lifecycle + +Disposal MUST stop the render loop, call the module's `deinit(canvas)` (detaching observers and +releasing the presenter's GL state), dispose the JavaScript module reference and the +`DotNetObjectReference`, and free the render buffer. Disposal MUST tolerate an already-closed +transport. + +## 8. Render loop and invalidation (all hosts) + +- `Invalidate()` requests one frame; if a size/DPR is not yet known it is a no-op until one is + reported. +- `EnableRenderLoop = true` produces frames continuously; `EnableRenderLoop = false` produces + frames only in response to `Invalidate()` (including the implicit `Invalidate()` from size, + DPR, `EnableRenderLoop` and `IgnorePixelScaling` changes). +- The application owns the frame rate. To run slower, an application disables the render loop + and drives frames with `Invalidate()`. +- The Direct strategy is paced by `requestAnimationFrame`; the Bridged strategy is paced by + transfer completion (§7.5). Both honour the same `EnableRenderLoop`/`Invalidate()` contract. + +## 9. JavaScript modules + +All modules are shipped as static web assets under +`_content/SkiaSharp.Views.Blazor/` and are ES modules. + +### 9.1 Module responsibilities and import rules + +| Module | Responsibility | Imported by | +|--------|----------------|-------------| +| `SKCanvasPresenter` | Pure-browser paint primitives (2D + WebGL). No emscripten, no .NET callbacks, no loop. | The Direct and Bridged modules (internally). | +| `SKHtmlCanvas` | WebAssembly Direct path: emscripten heap/GL access, the `requestAnimationFrame` loop and the .NET draw callback. | Direct strategy only. | +| `SKHtmlCanvasBridge` | Host-agnostic bridged present: `initialize`/`present`/`deinit`, metrics reporting. No emscripten. | Bridged strategy only. | +| `SizeWatcher` | `ResizeObserver` reporting CSS size. | Direct strategy. | +| `DpiWatcher` | ~1 s DPR poll. | Direct strategy. | + +A bridged host MUST NOT import `SKHtmlCanvas`: it references emscripten globals (`Module`, +`GL`) that exist only in a WebAssembly runtime, not in a Server client browser or a Hybrid +WebView. The Bridged module MUST perform its painting through `SKCanvasPresenter` so the +pixel-to-canvas logic is defined in one place; the Direct module SHOULD do the same, and today +still contains equivalent paint primitives inline (folding it onto `SKCanvasPresenter` is an +internal cleanup with no behavioural effect). + +### 9.2 `SKHtmlCanvas` (Direct) + +Provides `initGL`/`initRaster`, the `requestAnimationFrame` loop, `putImageData` (from the +emscripten heap) and emscripten WebGL context creation, as described in §6. + +### 9.3 `SKHtmlCanvasBridge` (Bridged) + +Provides `initialize(canvas, dotNetRef, isGL)` (attach `ResizeObserver` + DPR poll, report +metrics), `present(canvas, bytes, width, height, format, isGL)` (`format` is `"png"`, `"jpeg"` +or `"put"`) and `deinit(canvas)`, delegating painting to `SKCanvasPresenter`. + +### 9.4 `SizeWatcher` + +Observes an element with a `ResizeObserver` and reports `clientWidth`/`clientHeight`. + +### 9.5 `DpiWatcher` + +A shared instance that samples `window.devicePixelRatio` on a ~1 second interval and reports +changes; returns the current DPR immediately on subscribe. + +## 10. Interactive Auto + +A component MUST behave correctly across the Auto transition. Blazor renders the component with +Interactive Server on the first visit and with Interactive WebAssembly on later visits (after +the WebAssembly bundle is cached); it never changes the runtime of a component instance already +on the page. Because a single component adapts by host (§4) — Bridged on the Server leg, Direct +on the WebAssembly leg — no consumer action is required. The paint surface is stateless (the +application redraws in `OnPaintSurface`), so no state need be persisted across the transition. + +## 11. Static SSR + +A component prerendered under static SSR is not interactive: `OnAfterRender`/JavaScript interop +do not run, so no frames are presented and the `` remains in its initial state until the +component becomes interactive (for example under Interactive Server or Interactive Auto), at +which point the Bridged strategy begins presenting. Emitting a prerendered poster frame is a +permitted future enhancement and is not required by this specification. + +## 12. Framework support + +- The Direct strategy (WebAssembly) is supported on all target frameworks the package targets + (currently `net6.0`, `net9.0`, `net10.0`). +- The Bridged strategy (Server, Hybrid, static SSR) and the `RendererInfo`-based host detection + require **.NET 9.0 or later**. On earlier target frameworks the components are + WebAssembly-only and use the Direct strategy exclusively; the bridged-only parameters + (`TransferFormat`, `Quality`) have no effect there. + +## 13. Packaging and native assets + +- The WebAssembly native asset (`SkiaSharp.NativeAssets.WebAssembly`) and the emscripten + link-flag workaround are relevant only to WebAssembly builds and MUST NOT affect + Server/Hybrid consumers, which obtain the platform native `libSkiaSharp` from their own + `SkiaSharp` reference. +- All view code lives in the single `SkiaSharp.Views.Blazor` package so that one shared + component assembly satisfies both the server and client projects of an Interactive Auto app. + +## 14. Conformance map (spec → code) + +The following verifies that the current implementation satisfies this specification. Paths are +under `source/SkiaSharp.Views/SkiaSharp.Views.Blazor/`. + +| Spec | Implementation | Status | +|------|----------------|--------| +| §3.1 element + attribute splat | `SKCanvasView.razor`, `SKGLView.razor` | ✅ | +| §3.2 parameters | `SKCanvasView.razor.cs`, `SKGLView.razor.cs` | ✅ | +| §3.3 enum/options/DI | `SKBlazorTransferFormat.cs`, `SKBlazorOptions.cs`, `SKBlazorServiceCollectionExtensions.cs` | ✅ | +| §4 host detection + fallback | `Internal/Host.Resolve`, both views' `OnAfterRenderAsync` (`RendererInfo.Name`, `OperatingSystem.IsBrowser()`) | ✅ | +| §5.2 backing-store = cssSize×dpr | `CanvasDirectRenderer`/`GLDirectRenderer.CreateSize` and `BridgedRenderer.EnsureBuffer` | ✅ | +| §5.3 `IgnorePixelScaling` | `OnRenderFrame` (direct renderers) and `BridgedRenderer.RenderAndPresentAsync` | ✅ | +| §5.4 `Dpi` | `Dpi` property (delegates to the active `IRenderer`) | ✅ | +| §6.1 RAF loop | `Internal/SKHtmlCanvasInterop`, `wwwroot/SKHtmlCanvas.ts` | ✅ | +| §6.2 raster direct | `Internal/CanvasDirectRenderer` (`PlatformColorType`/`Opaque`, `PutImageData`) | ✅ | +| §6.3 GL direct | `Internal/GLDirectRenderer` (`GRContext`, `Rgba8888`, `BottomLeft`, 256 MB, WebGL2→1) | ✅ | +| §6.4 size/DPI watchers | `Internal/SizeWatcherInterop`, `Internal/DpiWatcherInterop`, `wwwroot/SizeWatcher.ts`, `wwwroot/DpiWatcher.ts` | ✅ | +| §7.1 init + metrics + no dropped first frame | `BridgedRenderer.InitializeAsync`/`OnMetricsChanged` (`initialized` set before JS `initialize`) | ✅ | +| §7.2 frame production (RGBA/Premul, reused buffer, off-thread encode) | `BridgedRenderer.RenderAndPresentAsync`/`EnsureBuffer` | ✅ | +| §7.3 present backends by view type | `wwwroot/SKCanvasPresenter.ts` (2D + WebGL), `wwwroot/SKHtmlCanvasBridge.ts` | ✅ | +| §7.4 formats + resolution precedence + quality | `Internal/FrameProducer`, `Internal/Host.ResolveTransferFormat` | ✅ | +| §7.5 backpressure + identical-frame suppression | `BridgedRenderer.RenderLoopAsync`/`RenderAndPresentAsync` | ✅ | +| §7.6 `SKGLView` bridged placeholder GL target | `BridgedRenderer.PaintFrame` (isGL branch) | ✅ | +| §7.7 disposal | `BridgedRenderer.DisposeAsync`, both views' `Dispose` | ✅ | +| §8 loop/invalidation contract | `Invalidate` (views) delegating to the active `IRenderer` | ✅ | +| §9.1 module import rules (bridge never imports `SKHtmlCanvas`) | `Internal/SKHtmlCanvasBridgeInterop`, `Internal/SKHtmlCanvasInterop` | ✅ | +| §10 Auto (single adaptive component) | strategy chosen per render in `OnAfterRenderAsync` | ✅ | +| §12 net9+ gating of bridged features | `#if NET9_0_OR_GREATER` in both views | ✅ | +| §11 static-SSR poster frame | not implemented (blank until interactive) | ⛔ future (permitted by spec) | +| §13 packaging non-leakage | verified at project-reference level (Server sample); NuGet-pack verification | ⚠️ pending | +| §9.1 Direct paint folded onto `SKCanvasPresenter` | `SKHtmlCanvas.ts` still paints inline | ⚠️ internal cleanup (no behavioural effect) | + +Automated verification: `tests/SkiaSharp.Tests.Blazor` covers §7.4 (frame producer), §4/§7.4 +(host + format resolution) and §3.3 (DI). The Blazor Server sample +(`samples/Basic/BlazorServer`) exercises §4/§5/§7/§8 end to end. diff --git a/samples/Basic/BlazorHybrid/README.md b/samples/Basic/BlazorHybrid/README.md new file mode 100644 index 00000000000..37cdf603f7a --- /dev/null +++ b/samples/Basic/BlazorHybrid/README.md @@ -0,0 +1,56 @@ +# SkiaSharp on Blazor Hybrid (.NET MAUI) + +This is the **same application** as the [Blazor WebAssembly](../BlazorWebAssembly) and +[Blazor Server](../BlazorServer) samples, packaged as a **.NET MAUI Blazor Hybrid** mobile app. +The pages, layout, styles, MudBlazor theme and SkiaSharp drawing code are identical — only the +host is different (a native `BlazorWebView` instead of a browser or a server). + +It demonstrates [#1194](https://github.com/mono/SkiaSharp/issues/1194): the same `SKCanvasView` +and `SKGLView` components run under Blazor Hybrid because the views detect their host at runtime +(a WebView) and render on the .NET side in-process, presenting frames into the WebView's +``. + +## Run + +```bash +# Mac (Mac Catalyst) +dotnet build SkiaSharpSample -t:Run -f net10.0-maccatalyst + +# Android (device or emulator running) +dotnet build SkiaSharpSample -t:Run -f net10.0-android + +# iOS (simulator or device) +dotnet build SkiaSharpSample -t:Run -f net10.0-ios +``` + +You should see the same three pages as the other samples, via the drawer: + +- **CPU** — `SKCanvasView` with gradients, shapes and text. +- **GPU** — `SKGLView` with a continuously animated SkSL shader (on a WebGL-backed canvas). +- **Drawing** — `SKCanvasView` with pointer input and on-demand rendering. + +## What differs from the WebAssembly / Server samples + +Only the host wiring — the shared pages, layout, scoped CSS, `FpsCounter`, `SamplePage` and +`wwwroot/css/app.css` are byte-identical. + +- Scaffolded from the `maui-blazor` template, so all the platform projects (`Platforms/`), + `MauiProgram.cs`, `App.xaml`, `MainPage.xaml` (the `BlazorWebView` hosting `Routes` at `#app`) + and `Resources/` are standard MAUI. +- `MauiProgram.cs` registers `AddMauiBlazorWebView()` and `AddMudServices()`. +- `wwwroot/index.html` is the WebView host page (loads `blazor.webview.js`, MudBlazor and + `css/app.css`). +- **Font loading:** `Home.razor` loads `NotoSans-Regular.ttf` as a **raw app-package asset** via + `FileSystem.OpenAppPackageFileAsync` (no `HttpClient`), since a native WebView has no server to + fetch from. The font is included both as a `Resources/Raw` asset (for the C# canvas text) and + in `wwwroot/fonts` (for the CSS `@font-face`). This is the only line that differs from the + WebAssembly/Server `Home.razor`. +- `Program.DefaultPage` (used by the shared `MainLayout`) is replaced with `App.DefaultPage` + in `App.xaml.cs` — the idiomatic MAUI place and the same approach as the native + [`samples/Basic/Maui`](../Maui) sample. This is the one line that differs in the Hybrid + `MainLayout.razor` from the WebAssembly/Server samples. +- `ValidateXcodeVersion=false` lets the sample build with a newer Xcode than the Apple SDK's exact + recommended version (as the repo's test projects do). + +See [documentation/dev/blazor-server-hybrid-rendering.md](../../../documentation/dev/blazor-server-hybrid-rendering.md) +for the design. diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/App.xaml b/samples/Basic/BlazorHybrid/SkiaSharpSample/App.xaml new file mode 100644 index 00000000000..371c6c16141 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/App.xaml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/App.xaml.cs b/samples/Basic/BlazorHybrid/SkiaSharpSample/App.xaml.cs new file mode 100644 index 00000000000..809f3d449ea --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/App.xaml.cs @@ -0,0 +1,17 @@ +namespace SkiaSharpSample; + +public partial class App : Application +{ + /// The page the sample starts on (mirrors the native MAUI sample). + public static SamplePage DefaultPage { get; set; } = SamplePage.Cpu; + + public App() + { + InitializeComponent(); + } + + protected override Window CreateWindow(IActivationState? activationState) + { + return new Window(new MainPage()) { Title = "SkiaSharpSample" }; + } +} diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/FpsCounter.cs b/samples/Basic/BlazorHybrid/SkiaSharpSample/FpsCounter.cs new file mode 100644 index 00000000000..136fcdccef6 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/FpsCounter.cs @@ -0,0 +1,50 @@ +using System.Diagnostics; + +namespace SkiaSharpSample; + +/// +/// Lightweight frame-rate counter. Call once per frame; +/// it returns the measured FPS each time the sampling interval elapses. +/// Also exposes so GPU pages can feed shader time +/// from the same clock without a separate . +/// +public sealed class FpsCounter +{ + private readonly Stopwatch stopwatch = new(); + + private int frameCount; + private double lastSampleTime; + + /// + /// Seconds between FPS samples (default 0.5 s). + /// + public double Interval { get; set; } = 0.5; + + /// + /// Wall-clock seconds since was called. + /// Useful as a shader iTime uniform. + /// + public float ElapsedSeconds => (float)stopwatch.Elapsed.TotalSeconds; + + public void Start() => stopwatch.Start(); + + public void Stop() => stopwatch.Stop(); + + /// + /// Record one frame. Returns the measured FPS when the sampling + /// interval has elapsed, or null otherwise. + /// + public double? Tick() + { + frameCount++; + var now = stopwatch.Elapsed.TotalSeconds; + var delta = now - lastSampleTime; + if (delta < Interval) + return null; + + var fps = frameCount / delta; + frameCount = 0; + lastSampleTime = now; + return fps; + } +} diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Layout/MainLayout.razor b/samples/Basic/BlazorHybrid/SkiaSharpSample/Layout/MainLayout.razor new file mode 100644 index 00000000000..bf77324e0ba --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Layout/MainLayout.razor @@ -0,0 +1,133 @@ +@inherits LayoutComponentBase +@inject NavigationManager Navigation + + + + + + + + + + SkiaSharp + + + + + + + + CPU + GPU + Drawing + + + + + @Body + + + + +@code { + bool _drawerOpen = true; + bool _isDarkMode; + ThemeMode _themeMode = ThemeMode.System; + MudThemeProvider _themeProvider = null!; + + readonly MudTheme _theme = new() + { + PaletteLight = new PaletteLight + { + Primary = "#594AE2", + AppbarBackground = "#594AE2", + DrawerBackground = "#f0eef6", + DrawerText = "#424242", + DrawerIcon = "#594AE2", + Background = "#fafafa", + Surface = "#ffffff", + }, + PaletteDark = new PaletteDark + { + Primary = "#776be7", + AppbarBackground = "#1e1e2d", + DrawerBackground = "#1a1a27", + Background = "#121218", + Surface = "#1e1e2d", + }, + LayoutProperties = new LayoutProperties + { + DefaultBorderRadius = "12px", + }, + }; + + string _themeIcon => _themeMode switch + { + ThemeMode.Light => Icons.Material.Filled.LightMode, + ThemeMode.Dark => Icons.Material.Filled.DarkMode, + _ => Icons.Material.Filled.SettingsBrightness, + }; + + string _themeTooltip => _themeMode switch + { + ThemeMode.Light => "Light mode (click for dark)", + ThemeMode.Dark => "Dark mode (click for system)", + _ => "System theme (click for light)", + }; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _isDarkMode = await _themeProvider.GetSystemDarkModeAsync(); + await _themeProvider.WatchSystemDarkModeAsync(OnSystemPreferenceChanged); + + var route = App.DefaultPage switch + { + SamplePage.Gpu => "/gpu", + SamplePage.Drawing => "/drawing", + _ => null, + }; + if (route is not null) + Navigation.NavigateTo(route); + + StateHasChanged(); + } + } + + Task OnSystemPreferenceChanged(bool isDark) + { + if (_themeMode == ThemeMode.System) + { + _isDarkMode = isDark; + StateHasChanged(); + } + return Task.CompletedTask; + } + + async Task CycleTheme() + { + _themeMode = _themeMode switch + { + ThemeMode.System => ThemeMode.Light, + ThemeMode.Light => ThemeMode.Dark, + ThemeMode.Dark => ThemeMode.System, + _ => ThemeMode.System, + }; + + _isDarkMode = _themeMode switch + { + ThemeMode.Light => false, + ThemeMode.Dark => true, + _ => await _themeProvider.GetSystemDarkModeAsync(), + }; + + StateHasChanged(); + } + + void ToggleDrawer() => _drawerOpen = !_drawerOpen; + + enum ThemeMode { System, Light, Dark } +} diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/MainPage.xaml b/samples/Basic/BlazorHybrid/SkiaSharpSample/MainPage.xaml new file mode 100644 index 00000000000..da28f20ddee --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/MainPage.xaml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/MainPage.xaml.cs b/samples/Basic/BlazorHybrid/SkiaSharpSample/MainPage.xaml.cs new file mode 100644 index 00000000000..0536cd235be --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/MainPage.xaml.cs @@ -0,0 +1,9 @@ +namespace SkiaSharpSample; + +public partial class MainPage : ContentPage +{ + public MainPage() + { + InitializeComponent(); + } +} diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/MauiProgram.cs b/samples/Basic/BlazorHybrid/SkiaSharpSample/MauiProgram.cs new file mode 100644 index 00000000000..86ed61ad36f --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/MauiProgram.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; +using MudBlazor.Services; + +namespace SkiaSharpSample; + +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp() + .ConfigureFonts(fonts => + { + fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); + }); + + builder.Services.AddMauiBlazorWebView(); + builder.Services.AddMudServices(); + +#if DEBUG + builder.Services.AddBlazorWebViewDeveloperTools(); + builder.Logging.AddDebug(); +#endif + + return builder.Build(); + } +} diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Pages/Drawing.razor b/samples/Basic/BlazorHybrid/SkiaSharpSample/Pages/Drawing.razor new file mode 100644 index 00000000000..16cc8216495 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Pages/Drawing.razor @@ -0,0 +1,199 @@ +@page "/drawing" +@implements IDisposable + +@* Drawing Canvas Demo + Demonstrates SKCanvasView with pointer events, wheel delta for + brush size control, and basic stroke rendering. + Shows multi-stroke drawing with color picker and on-demand rendering + via manual Invalidate() (no render loop). *@ + +
+ + + + + +
+
+ @foreach (var (name, darkName, color, darkColor) in colors) + { + var c = _isDarkMode ? darkColor : color; + var css = _isDarkMode ? darkName : name; +
+ } +
+
+ + @($"{brushSize:F0}") +
+
+ +
+ +@code { + + [CascadingParameter(Name = "IsDarkMode")] + bool _isDarkMode { get; set; } + + bool _prevDarkMode; + SKCanvasView skiaView = null!; + float brushSize = 4f; + SKColor currentColor = SKColors.Black; + List strokes = []; + DrawingStroke? currentStroke = null; + SKPoint lastPoint; + + protected override void OnParametersSet() + { + if (_prevDarkMode != _isDarkMode) + { + _prevDarkMode = _isDarkMode; + // Swap the default color when toggling themes + if (_isDarkMode && currentColor == SKColors.Black) + currentColor = SKColors.White; + else if (!_isDarkMode && currentColor == SKColors.White) + currentColor = SKColors.Black; + skiaView?.Invalidate(); + } + } + + // The paint that is reused for drawing strokes. + readonly SKPaint strokePaint = new() + { + IsAntialias = true, + Style = SKPaintStyle.Stroke, + StrokeCap = SKStrokeCap.Round, + StrokeJoin = SKStrokeJoin.Round, + }; + + // Colors with light and dark variants — brighter in dark mode for visibility. + // Matches the Android Material 3 drawing palette. + static readonly (string Name, string DarkName, SKColor Color, SKColor DarkColor)[] colors = + { + ("#000000", "#FFFFFF", new SKColor(0x00, 0x00, 0x00), new SKColor(0xFF, 0xFF, 0xFF)), + ("#E53935", "#EF5350", new SKColor(0xE5, 0x39, 0x35), new SKColor(0xEF, 0x53, 0x50)), + ("#1E88E5", "#42A5F5", new SKColor(0x1E, 0x88, 0xE5), new SKColor(0x42, 0xA5, 0xF5)), + ("#43A047", "#66BB6A", new SKColor(0x43, 0xA0, 0x47), new SKColor(0x66, 0xBB, 0x6A)), + ("#FB8C00", "#FFA726", new SKColor(0xFB, 0x8C, 0x00), new SKColor(0xFF, 0xA7, 0x26)), + ("#8E24AA", "#AB47BC", new SKColor(0x8E, 0x24, 0xAA), new SKColor(0xAB, 0x47, 0xBC)), + }; + + SKColor CanvasBackground => _isDarkMode ? new SKColor(0x11, 0x13, 0x18) : SKColors.White; + + void OnPaintSurface(SKPaintSurfaceEventArgs e) + { + var canvas = e.Surface.Canvas; + + canvas.Clear(CanvasBackground); + + // Draw completed strokes. + foreach (var stroke in strokes) + { + strokePaint.Color = stroke.Color; + strokePaint.StrokeWidth = stroke.StrokeWidth; + canvas.DrawPath(stroke.Path, strokePaint); + } + + // Draw in-progress stroke. + if (currentStroke is not null) + { + strokePaint.Color = currentStroke.Color; + strokePaint.StrokeWidth = currentStroke.StrokeWidth; + canvas.DrawPath(currentStroke.Path, strokePaint); + } + + // Brush size indicator at last pointer position. + strokePaint.Color = SKColors.Gray; + strokePaint.StrokeWidth = 1; + canvas.DrawCircle(lastPoint, brushSize / 2f, strokePaint); + } + + void OnPointerDown(PointerEventArgs e) + { + if (!e.IsPrimary) + return; + lastPoint = new SKPoint((float)e.OffsetX, (float)e.OffsetY); + currentStroke = new DrawingStroke(new(), currentColor, brushSize); + currentStroke.Path.MoveTo(lastPoint); + skiaView.Invalidate(); + } + + void OnPointerMove(PointerEventArgs e) + { + if (!e.IsPrimary) + return; + lastPoint = new SKPoint((float)e.OffsetX, (float)e.OffsetY); + if (currentStroke is not null) + currentStroke.Path.LineTo(lastPoint); + skiaView.Invalidate(); + } + + void OnPointerUp(PointerEventArgs e) + { + if (!e.IsPrimary) + return; + if (currentStroke is not null) + { + strokes.Add(currentStroke); + currentStroke = null; + skiaView.Invalidate(); + } + } + + void OnWheel(WheelEventArgs e) + { + // DeltaY > 0 = scroll down = decrease brush size; DeltaY < 0 = scroll up = increase. + // Normalize ~100 CSS pixels per notch to ~1px brush change per notch. + brushSize -= (float)(e.DeltaY / 100.0); + brushSize = Math.Clamp(brushSize, 1f, 50f); + StateHasChanged(); + } + + void OnSliderInput(ChangeEventArgs e) + { + if (float.TryParse(e.Value?.ToString(), out var val)) + { + brushSize = val; + StateHasChanged(); + } + } + + void SetColor(SKColor color) + { + currentColor = color; + skiaView.Invalidate(); + StateHasChanged(); + } + + void ClearCanvas() + { + foreach (var stroke in strokes) + { + stroke.Path.Dispose(); + } + strokes.Clear(); + currentStroke?.Path.Dispose(); + currentStroke = null; + skiaView.Invalidate(); + StateHasChanged(); + } + + // Represents a single drawing stroke with its path, color, and width. + // Path is owned — callers must dispose when removing strokes. + record DrawingStroke(SKPath Path, SKColor Color, float StrokeWidth = 4); + + public void Dispose() + { + foreach (var stroke in strokes) + stroke.Path.Dispose(); + strokes.Clear(); + currentStroke?.Path.Dispose(); + currentStroke = null; + strokePaint?.Dispose(); + } +} diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Pages/Drawing.razor.css b/samples/Basic/BlazorHybrid/SkiaSharpSample/Pages/Drawing.razor.css new file mode 100644 index 00000000000..0dbb459ac9d --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Pages/Drawing.razor.css @@ -0,0 +1,90 @@ +.canvas-container { + position: relative; + overflow: hidden; +} + +.clear-btn { + position: absolute; + top: 12px; + right: 12px; + z-index: 1; + background: var(--pill-bg); + border: 1px solid var(--pill-border); + border-radius: 24px; + padding: 6px 20px; + backdrop-filter: blur(8px); + color: var(--pill-text); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.clear-btn:hover { + background: rgba(0,0,0,0.85); +} + +.overlay-toolbar { + position: absolute; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + z-index: 1; + background: var(--pill-bg); + border: 1px solid var(--pill-border); + border-radius: 28px; + padding: 14px 24px; + backdrop-filter: blur(8px); +} + +.swatch-row { + display: flex; + gap: 14px; + align-items: center; +} + +.color-swatch { + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + border: 2px solid transparent; + transition: transform 0.2s, box-shadow 0.2s; +} + +.color-swatch:hover { + transform: scale(1.15); +} + +.color-swatch.selected { + transform: scale(1.3); + border-color: transparent; + box-shadow: 0 0 0 2px rgba(255,255,255,0.8); +} + +.slider-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; +} + +.brush-slider { + flex: 1; + min-width: 160px; + accent-color: white; + cursor: pointer; +} + +.size-label { + color: var(--pill-text); + font-size: 0.8rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + min-width: 24px; + text-align: right; +} diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Pages/GPU.razor b/samples/Basic/BlazorHybrid/SkiaSharpSample/Pages/GPU.razor new file mode 100644 index 00000000000..579fd9741eb --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Pages/GPU.razor @@ -0,0 +1,200 @@ +@page "/gpu" +@implements IDisposable + +@* GPU Canvas Demo + Demonstrates SKGLView with EnableRenderLoop for continuous animation, + SKRuntimeEffect.BuildShader() for compiling SkSL shaders, and + touch interaction via uniforms. The entire visual is rendered in SkSL — + no C# draw calls. C# only handles touch input and passes uniforms. *@ + +
+ + + +
+ FPS: @($"{displayFps:F0}") +
+ +
+ +@code { + + // SkSL shader source: a "lava lamp" metaball effect. + // 6 colored blobs orbit on Lissajous curves. When the user touches, + // an extra white-hot blob appears at the touch position and merges + // with the others. All rendering is per-pixel in the shader. + const string sksl = @" + // Uniforms passed from C# each frame + uniform float iTime; // elapsed seconds + uniform float2 iResolution; // canvas size in pixels + uniform float2 iTouchPos; // touch position normalized 0..1 + uniform float iTouchActive; // 1.0 when touching, 0.0 otherwise + uniform float3 iColors[6]; // blob color palette + + half4 main(float2 fragCoord) { + // Convert pixel coords to normalized UV and aspect-corrected coords + float2 uv = fragCoord / iResolution; + float aspect = iResolution.x / iResolution.y; + float2 st = float2(uv.x * aspect, uv.y); + float t = iTime; + + // Metaball field: accumulate 1/r² contributions from each blob. + // 'weighted' tracks color contribution weighted by field strength. + float field = 0.0; + float3 weighted = float3(0.0); + + // Each blob orbits on a Lissajous curve with unique phase and speed + for (int i = 0; i < 6; i++) { + float fi = float(i); + float phase = fi * 1.047; // evenly spaced: 2*pi/6 + float speed = 0.3 + fi * 0.07; + + // Lissajous orbit center + float2 center = float2( + aspect * 0.5 + 0.4 * sin(t * speed + phase) * cos(t * speed * 0.6 + fi), + 0.5 + 0.4 * cos(t * speed * 0.8 + phase * 1.3) * sin(t * speed * 0.4 + fi * 0.7) + ); + + // Metaball field: strength falls off as 1/r², creating smooth merging + float2 d = st - center; + float r = length(d); + float strength = 0.030 / (r * r + 0.002); + field += strength; + weighted += iColors[i] * strength; + } + + // Touch interaction: add an extra, larger blob at the touch position + if (iTouchActive > 0.5) { + float2 touchSt = float2(iTouchPos.x * aspect, iTouchPos.y); + float2 d = st - touchSt; + float r = length(d); + float strength = 0.050 / (r * r + 0.002); + field += strength; + // white-hot touch blob + weighted += float3(1.0, 0.95, 0.9) * strength; + } + + // Normalize accumulated color by field strength for smooth blending + float3 blobColor = weighted / max(field, 0.001); + + // Threshold the field into blob edges with smooth falloff + float edge = smoothstep(5.0, 8.0, field); + float innerGlow = smoothstep(8.0, 20.0, field) * 0.3; + + // Subtly animated dark background + float3 bg = float3(0.03, 0.02, 0.08); + bg += float3(0.02, 0.01, 0.03) * sin(t * 0.2 + uv.y * 3.0); + + // Outer glow: visible between the halo and solid edge + float halo = smoothstep(3.0, 5.0, field) * (1.0 - edge); + + // Composite: background + halo + solid blobs with inner glow + float3 result = bg; + result += blobColor * halo * 0.4; + result = mix(result, blobColor * (1.0 + innerGlow), edge); + + // Vignette: darken corners for depth + float2 vc = uv - 0.5; + float vignette = 1.0 - dot(vc, vc) * 0.8; + result *= vignette; + + return half4(clamp(result, 0.0, 1.0), 1.0); + } + "; + + static readonly float[] blobColors = + { + 1.0f, 0.3f, 0.4f, // hot pink + 0.3f, 0.7f, 1.0f, // sky blue + 1.0f, 0.6f, 0.1f, // orange + 0.4f, 1.0f, 0.7f, // mint + 0.7f, 0.3f, 1.0f, // purple + 1.0f, 0.9f, 0.2f, // yellow + }; + + // SKRuntimeShaderBuilder: compiled once via BuildShader(), then reused. + // Each frame we update uniforms and call Build() to get a new SKShader. + Lazy shaderBuilder = new(() => SKRuntimeEffect.BuildShader(sksl)); + + readonly FpsCounter fpsCounter = new(); + + // The paint that is reused for drawing the shader quad. + readonly SKPaint shaderPaint = new(); + + // Touch state passed to the shader as uniforms. + SKPoint touchPos; + bool touchActive; + + // FPS display value, updated each sampling interval. + double displayFps; + + protected override void OnInitialized() + { + fpsCounter.Start(); + } + + // Handle pointer down: start tracking touch position. + void OnPointerDown(PointerEventArgs e) + { + if (!e.IsPrimary) + return; + touchPos = new SKPoint((float)e.OffsetX, (float)e.OffsetY); + touchActive = true; + } + + // Handle pointer move: update touch position while pointer is pressed. + void OnPointerMove(PointerEventArgs e) + { + if (!e.IsPrimary || e.Buttons == 0) + return; + touchPos = new SKPoint((float)e.OffsetX, (float)e.OffsetY); + } + + // Handle pointer up: clear touch state. + void OnPointerUp(PointerEventArgs e) + { + if (!e.IsPrimary) + return; + touchActive = false; + } + + // Called every frame by SKGLView's render loop. + void OnPaintSurface(SKPaintGLSurfaceEventArgs e) + { + var canvas = e.Surface.Canvas; + var width = e.Info.Width; + var height = e.Info.Height; + + // Update uniforms — no allocations, reuses the builder's internal storage. + var builder = shaderBuilder.Value; + builder.Uniforms["iTime"] = fpsCounter.ElapsedSeconds; + builder.Uniforms["iResolution"] = new float[] { width, height }; + builder.Uniforms["iTouchPos"] = new float[] { + touchActive ? touchPos.X / width : -1f, + touchActive ? touchPos.Y / height : -1f + }; + builder.Uniforms["iTouchActive"] = touchActive ? 1f : 0f; + builder.Uniforms["iColors"] = blobColors; + + // Build() creates a shader from cached effect + current uniforms. + // Draw a full-screen quad to run the shader on every pixel. + using var shader = builder.Build(); + shaderPaint.Shader = shader; + canvas.DrawRect(0, 0, width, height, shaderPaint); + + if (fpsCounter.Tick() is double fps) + { + displayFps = fps; + _ = InvokeAsync(StateHasChanged); + } + } + + public void Dispose() + { + if (shaderBuilder?.IsValueCreated == true) + shaderBuilder.Value.Dispose(); + shaderPaint?.Dispose(); + } +} diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Pages/GPU.razor.css b/samples/Basic/BlazorHybrid/SkiaSharpSample/Pages/GPU.razor.css new file mode 100644 index 00000000000..6c31c601660 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Pages/GPU.razor.css @@ -0,0 +1,20 @@ +.canvas-container { + position: relative; +} + +.overlay-stats { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 1; + background: var(--pill-bg); + border: 1px solid var(--pill-border); + border-radius: 24px; + padding: 6px 20px; + backdrop-filter: blur(8px); + color: var(--pill-text); + font-size: 0.85rem; + font-weight: 500; + font-variant-numeric: tabular-nums; +} diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Pages/Home.razor b/samples/Basic/BlazorHybrid/SkiaSharpSample/Pages/Home.razor new file mode 100644 index 00000000000..975167cc811 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Pages/Home.razor @@ -0,0 +1,85 @@ +@page "/" + +@* Home Page + Demonstrates basic SKCanvasView usage with gradients, shapes, and text. + Shows SKShader.CreateRadialGradient, DrawCircle, DrawText, and SKFont. *@ + +
+ + + +
+ +@code { + + static SKTypeface? notoSans; + + static readonly (float X, float Y, float R, SKColor Color)[] circles = + { + (0.20f, 0.30f, 0.10f, new SKColor(0xFF, 0x4D, 0x66, 0xCC)), + (0.75f, 0.25f, 0.08f, new SKColor(0x4D, 0xB3, 0xFF, 0xCC)), + (0.15f, 0.70f, 0.07f, new SKColor(0xFF, 0x99, 0x1A, 0xCC)), + (0.80f, 0.70f, 0.12f, new SKColor(0x66, 0xFF, 0xB3, 0xCC)), + (0.50f, 0.15f, 0.06f, new SKColor(0xB3, 0x4D, 0xFF, 0xCC)), + (0.40f, 0.80f, 0.09f, new SKColor(0xFF, 0xE6, 0x33, 0xCC)), + }; + + static readonly SKColor[] gradientColors = + { + new SKColor(0x44, 0x88, 0xFF), + new SKColor(0x88, 0x33, 0xCC), + }; + + protected override async Task OnInitializedAsync() + { + if (notoSans != null) + return; + + // MAUI Hybrid: the font is a raw app-package asset, loaded from the file system + // (no HTTP round-trip needed). The rest of this page is identical to the + // WebAssembly/Server samples. + using var stream = await FileSystem.OpenAppPackageFileAsync("NotoSans-Regular.ttf"); + notoSans = SKTypeface.FromStream(stream); + } + + void OnPaintSurface(SKPaintSurfaceEventArgs e) + { + var canvas = e.Surface.Canvas; + var width = e.Info.Width; + var height = e.Info.Height; + var center = new SKPoint(width / 2f, height / 2f); + var radius = Math.Max(width, height) / 2f; + + canvas.Clear(SKColors.White); + + // Background gradient + using var shader = SKShader.CreateRadialGradient(center, radius, gradientColors, SKShaderTileMode.Clamp); + using var bgPaint = new SKPaint + { + IsAntialias = true, + Shader = shader, + }; + canvas.DrawRect(0, 0, width, height, bgPaint); + + // Circles + using var circlePaint = new SKPaint + { + IsAntialias = true, + Style = SKPaintStyle.Fill, + }; + foreach (var (x, y, r, color) in circles) + { + circlePaint.Color = color; + canvas.DrawCircle(x * width, y * height, r * Math.Min(width, height), circlePaint); + } + + // Centered text + using var textPaint = new SKPaint + { + Color = SKColors.White, + IsAntialias = true, + }; + using var font = new SKFont(notoSans, width * 0.12f); + canvas.DrawText("SkiaSharp", center.X, center.Y + font.Size / 3f, SKTextAlign.Center, font, textPaint); + } +} diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Android/AndroidManifest.xml b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Android/AndroidManifest.xml new file mode 100644 index 00000000000..dbf9e7e5312 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Android/MainActivity.cs b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Android/MainActivity.cs new file mode 100644 index 00000000000..53ea0ad9ef7 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Android/MainActivity.cs @@ -0,0 +1,10 @@ +using Android.App; +using Android.Content.PM; +using Android.OS; + +namespace SkiaSharpSample; + +[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] +public class MainActivity : MauiAppCompatActivity +{ +} diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Android/MainApplication.cs b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Android/MainApplication.cs new file mode 100644 index 00000000000..745b4fe9961 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Android/MainApplication.cs @@ -0,0 +1,15 @@ +using Android.App; +using Android.Runtime; + +namespace SkiaSharpSample; + +[Application] +public class MainApplication : MauiApplication +{ + public MainApplication(IntPtr handle, JniHandleOwnership ownership) + : base(handle, ownership) + { + } + + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Android/Resources/values/colors.xml b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Android/Resources/values/colors.xml new file mode 100644 index 00000000000..c04d7492abf --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Android/Resources/values/colors.xml @@ -0,0 +1,6 @@ + + + #512BD4 + #2B0B98 + #2B0B98 + \ No newline at end of file diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/MacCatalyst/AppDelegate.cs b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/MacCatalyst/AppDelegate.cs new file mode 100644 index 00000000000..56b1ab77ff1 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/MacCatalyst/AppDelegate.cs @@ -0,0 +1,9 @@ +using Foundation; + +namespace SkiaSharpSample; + +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/MacCatalyst/Entitlements.plist b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/MacCatalyst/Entitlements.plist new file mode 100644 index 00000000000..de4adc94a9c --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/MacCatalyst/Entitlements.plist @@ -0,0 +1,14 @@ + + + + + + + com.apple.security.app-sandbox + + + com.apple.security.network.client + + + + diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/MacCatalyst/Info.plist b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/MacCatalyst/Info.plist new file mode 100644 index 00000000000..7b1e6e6219c --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/MacCatalyst/Info.plist @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + UIDeviceFamily + + 2 + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + XSAppIconAssets + Assets.xcassets/appicon.appiconset + + diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/MacCatalyst/Program.cs b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/MacCatalyst/Program.cs new file mode 100644 index 00000000000..7dbf7ddaa2e --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/MacCatalyst/Program.cs @@ -0,0 +1,15 @@ +using ObjCRuntime; +using UIKit; + +namespace SkiaSharpSample; + +public class Program +{ + // This is the main entry point of the application. + static void Main(string[] args) + { + // if you want to use a different Application Delegate class from "AppDelegate" + // you can specify it here. + UIApplication.Main(args, null, typeof(AppDelegate)); + } +} \ No newline at end of file diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Windows/App.xaml b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Windows/App.xaml new file mode 100644 index 00000000000..dd574369e4c --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Windows/App.xaml @@ -0,0 +1,8 @@ + + + diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Windows/App.xaml.cs b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Windows/App.xaml.cs new file mode 100644 index 00000000000..6e630e3a628 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Windows/App.xaml.cs @@ -0,0 +1,24 @@ +using Microsoft.UI.Xaml; + +// To learn more about WinUI, the WinUI project structure, +// and more about our project templates, see: http://aka.ms/winui-project-info. + +namespace SkiaSharpSample.WinUI; + +/// +/// Provides application-specific behavior to supplement the default Application class. +/// +public partial class App : MauiWinUIApplication +{ + /// + /// Initializes the singleton application object. This is the first line of authored code + /// executed, and as such is the logical equivalent of main() or WinMain(). + /// + public App() + { + this.InitializeComponent(); + } + + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} + diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Windows/Package.appxmanifest b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Windows/Package.appxmanifest new file mode 100644 index 00000000000..ea14d79131e --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Windows/Package.appxmanifest @@ -0,0 +1,46 @@ + + + + + + + + + $placeholder$ + User Name + $placeholder$.png + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Windows/app.manifest b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Windows/app.manifest new file mode 100644 index 00000000000..7609ce70b70 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/Windows/app.manifest @@ -0,0 +1,17 @@ + + + + + + + + true/PM + PerMonitorV2, PerMonitor + + true + + + diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/iOS/AppDelegate.cs b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/iOS/AppDelegate.cs new file mode 100644 index 00000000000..56b1ab77ff1 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/iOS/AppDelegate.cs @@ -0,0 +1,9 @@ +using Foundation; + +namespace SkiaSharpSample; + +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/iOS/Info.plist b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/iOS/Info.plist new file mode 100644 index 00000000000..ecb7f719bd2 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/iOS/Info.plist @@ -0,0 +1,32 @@ + + + + + LSRequiresIPhoneOS + + UIDeviceFamily + + 1 + 2 + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + XSAppIconAssets + Assets.xcassets/appicon.appiconset + + diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/iOS/Program.cs b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/iOS/Program.cs new file mode 100644 index 00000000000..bfc102b77a7 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/iOS/Program.cs @@ -0,0 +1,15 @@ +using ObjCRuntime; +using UIKit; + +namespace SkiaSharpSample; + +public class Program +{ + // This is the main entry point of the application. + static void Main(string[] args) + { + // if you want to use a different Application Delegate class from "AppDelegate" + // you can specify it here. + UIApplication.Main(args, null, typeof(AppDelegate)); + } +} diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/iOS/Resources/PrivacyInfo.xcprivacy b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/iOS/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 00000000000..24ab3b4334c --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Platforms/iOS/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,51 @@ + + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + + + + + + diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Properties/launchSettings.json b/samples/Basic/BlazorHybrid/SkiaSharpSample/Properties/launchSettings.json new file mode 100644 index 00000000000..4f857936f4f --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Windows Machine": { + "commandName": "Project", + "nativeDebugging": false + } + } +} \ No newline at end of file diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/AppIcon/appicon.svg b/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/AppIcon/appicon.svg new file mode 100644 index 00000000000..9d63b6513a1 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/AppIcon/appicon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/AppIcon/appiconfg.svg b/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/AppIcon/appiconfg.svg new file mode 100644 index 00000000000..21dfb25f187 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/AppIcon/appiconfg.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/Fonts/OpenSans-Regular.ttf b/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/Fonts/OpenSans-Regular.ttf new file mode 100644 index 00000000000..29bfd35a2bf Binary files /dev/null and b/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/Fonts/OpenSans-Regular.ttf differ diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/Images/dotnet_bot.svg b/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/Images/dotnet_bot.svg new file mode 100644 index 00000000000..abfaff26a85 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/Images/dotnet_bot.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/Raw/AboutAssets.txt b/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/Raw/AboutAssets.txt new file mode 100644 index 00000000000..6de1c152706 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/Raw/AboutAssets.txt @@ -0,0 +1,15 @@ +Any raw assets you want to be deployed with your application can be placed in +this directory (and child directories). Deployment of the asset to your application +is automatically handled by the following `MauiAsset` Build Action within your `.csproj`. + + + +These files will be deployed with your package and will be accessible using Essentials: + + async Task LoadMauiAsset() + { + using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt"); + using var reader = new StreamReader(stream); + + var contents = reader.ReadToEnd(); + } diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/Raw/NotoSans-Regular.ttf b/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/Raw/NotoSans-Regular.ttf new file mode 100644 index 00000000000..4bac02f2f46 Binary files /dev/null and b/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/Raw/NotoSans-Regular.ttf differ diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/Splash/splash.svg b/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/Splash/splash.svg new file mode 100644 index 00000000000..21dfb25f187 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Resources/Splash/splash.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/Routes.razor b/samples/Basic/BlazorHybrid/SkiaSharpSample/Routes.razor new file mode 100644 index 00000000000..17c91376f80 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/Routes.razor @@ -0,0 +1,12 @@ + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/SamplePage.cs b/samples/Basic/BlazorHybrid/SkiaSharpSample/SamplePage.cs new file mode 100644 index 00000000000..0948902a3d0 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/SamplePage.cs @@ -0,0 +1,11 @@ +namespace SkiaSharpSample; + +/// +/// The pages available in this sample. +/// +public enum SamplePage +{ + Cpu, + Gpu, + Drawing, +} diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/SkiaSharpSample.csproj b/samples/Basic/BlazorHybrid/SkiaSharpSample/SkiaSharpSample.csproj new file mode 100644 index 00000000000..8cad51cb263 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/SkiaSharpSample.csproj @@ -0,0 +1,72 @@ + + + + net10.0-android;net10.0-ios;net10.0-maccatalyst + $(TargetFrameworks);net10.0-windows10.0.19041.0 + + Exe + SkiaSharpSample + true + true + enable + enable + + + false + + + false + true + true + + + false + + SkiaSharp Hybrid + com.companyname.skiasharpsample.hybrid + 1.0 + 1 + + None + + 15.0 + 15.0 + 24.0 + 10.0.17763.0 + 10.0.17763.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/_Imports.razor b/samples/Basic/BlazorHybrid/SkiaSharpSample/_Imports.razor new file mode 100644 index 00000000000..281d9a18315 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/_Imports.razor @@ -0,0 +1,14 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Microsoft.Maui.Storage +@using SkiaSharp +@using SkiaSharp.Views.Blazor +@using MudBlazor +@using SkiaSharpSample +@using SkiaSharpSample.Layout +@using SkiaSharpSample.Pages diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/wwwroot/css/app.css b/samples/Basic/BlazorHybrid/SkiaSharpSample/wwwroot/css/app.css new file mode 100644 index 00000000000..67652808903 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/wwwroot/css/app.css @@ -0,0 +1,88 @@ +:root { + --pill-bg: rgba(0,0,0,0.75); + --pill-border: rgba(255,255,255,0.15); + --pill-text: #ffffff; +} + +html, body { + height: 100%; + margin: 0; + overflow: hidden; +} + +#app { + height: 100%; +} + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.loading-progress { + position: relative; + display: block; + width: 8rem; + height: 8rem; + margin: 20vh auto 1rem auto; +} + + .loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); + } + + .loading-progress circle:last-child { + stroke: #1b6ec2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; + } + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + + .loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); + } + +.canvas-container { + line-height: 1; + flex: 1; + min-height: 0; +} + + .canvas-container canvas { + width: 100%; + height: 100%; + } diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/wwwroot/favicon.ico b/samples/Basic/BlazorHybrid/SkiaSharpSample/wwwroot/favicon.ico new file mode 100644 index 00000000000..481d8e4c377 Binary files /dev/null and b/samples/Basic/BlazorHybrid/SkiaSharpSample/wwwroot/favicon.ico differ diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/wwwroot/fonts/NotoSans-Regular.ttf b/samples/Basic/BlazorHybrid/SkiaSharpSample/wwwroot/fonts/NotoSans-Regular.ttf new file mode 100644 index 00000000000..4bac02f2f46 Binary files /dev/null and b/samples/Basic/BlazorHybrid/SkiaSharpSample/wwwroot/fonts/NotoSans-Regular.ttf differ diff --git a/samples/Basic/BlazorHybrid/SkiaSharpSample/wwwroot/index.html b/samples/Basic/BlazorHybrid/SkiaSharpSample/wwwroot/index.html new file mode 100644 index 00000000000..434f2f6f285 --- /dev/null +++ b/samples/Basic/BlazorHybrid/SkiaSharpSample/wwwroot/index.html @@ -0,0 +1,40 @@ + + + + + + + SkiaSharpSample + + + + + + + + + + +
+ +
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + + diff --git a/samples/Basic/BlazorServer/README.md b/samples/Basic/BlazorServer/README.md new file mode 100644 index 00000000000..ad82ed97b84 --- /dev/null +++ b/samples/Basic/BlazorServer/README.md @@ -0,0 +1,44 @@ +# SkiaSharp on Blazor Server + +This is the **same application** as the [Blazor WebAssembly sample](../BlazorWebAssembly), +hosted with **Blazor Server** instead. The pages, layout, styles, MudBlazor theme and SkiaSharp +drawing code are identical — only the hosting model differs. + +It demonstrates [#1194](https://github.com/mono/SkiaSharp/issues/1194): the same `SKCanvasView` +and `SKGLView` components that run in Blazor WebAssembly also work under Blazor Server, because +the views detect their host at runtime and switch to a bridged rendering path (SkiaSharp renders +on the server; frames are streamed to the browser and painted into the ``). + +## Run + +```bash +cd SkiaSharpSample +dotnet run +``` + +Open the printed URL (for example `http://localhost:5000`). You should see the same three pages +as the WebAssembly sample: + +- **CPU** — `SKCanvasView` with gradients, shapes and text. +- **GPU** — `SKGLView` with a continuously animated SkSL shader. +- **Drawing** — `SKCanvasView` with pointer/wheel input and on-demand rendering. + +## What differs from the WebAssembly sample + +Only the host wiring: + +- `SkiaSharpSample.csproj` uses the Web SDK and does not reference the WebAssembly packages or + the WebAssembly native-asset props. +- `Program.cs` builds a `WebApplication`, calls `AddRazorComponents().AddInteractiveServerComponents()` + and `MapRazorComponents().AddInteractiveServerRenderMode()`, and registers the same + `HttpClient` and MudBlazor services. +- `App.razor` is the host document (the equivalent of the WebAssembly `wwwroot/index.html`) and + loads `blazor.web.js`; `Routes.razor` holds the router that was `App.razor` in the WebAssembly + sample. + +Everything else — `Layout/MainLayout.razor`, `Pages/Home.razor`, `Pages/GPU.razor`, +`Pages/Drawing.razor`, the scoped `*.razor.css`, `wwwroot/css/app.css`, the font and +`FpsCounter`/`SamplePage` — is copied verbatim. + +See [documentation/dev/blazor-server-hybrid-rendering.md](../../../documentation/dev/blazor-server-hybrid-rendering.md) +for the design. diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/App.razor b/samples/Basic/BlazorServer/SkiaSharpSample/App.razor new file mode 100644 index 00000000000..bac582eac4b --- /dev/null +++ b/samples/Basic/BlazorServer/SkiaSharpSample/App.razor @@ -0,0 +1,38 @@ + + + + + + + SkiaSharpSample + + + + + + + + + + + + + +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/FpsCounter.cs b/samples/Basic/BlazorServer/SkiaSharpSample/FpsCounter.cs new file mode 100644 index 00000000000..136fcdccef6 --- /dev/null +++ b/samples/Basic/BlazorServer/SkiaSharpSample/FpsCounter.cs @@ -0,0 +1,50 @@ +using System.Diagnostics; + +namespace SkiaSharpSample; + +/// +/// Lightweight frame-rate counter. Call once per frame; +/// it returns the measured FPS each time the sampling interval elapses. +/// Also exposes so GPU pages can feed shader time +/// from the same clock without a separate . +/// +public sealed class FpsCounter +{ + private readonly Stopwatch stopwatch = new(); + + private int frameCount; + private double lastSampleTime; + + /// + /// Seconds between FPS samples (default 0.5 s). + /// + public double Interval { get; set; } = 0.5; + + /// + /// Wall-clock seconds since was called. + /// Useful as a shader iTime uniform. + /// + public float ElapsedSeconds => (float)stopwatch.Elapsed.TotalSeconds; + + public void Start() => stopwatch.Start(); + + public void Stop() => stopwatch.Stop(); + + /// + /// Record one frame. Returns the measured FPS when the sampling + /// interval has elapsed, or null otherwise. + /// + public double? Tick() + { + frameCount++; + var now = stopwatch.Elapsed.TotalSeconds; + var delta = now - lastSampleTime; + if (delta < Interval) + return null; + + var fps = frameCount / delta; + frameCount = 0; + lastSampleTime = now; + return fps; + } +} diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/Layout/MainLayout.razor b/samples/Basic/BlazorServer/SkiaSharpSample/Layout/MainLayout.razor new file mode 100644 index 00000000000..c0f721d8ea6 --- /dev/null +++ b/samples/Basic/BlazorServer/SkiaSharpSample/Layout/MainLayout.razor @@ -0,0 +1,133 @@ +@inherits LayoutComponentBase +@inject NavigationManager Navigation + + + + + + + + + + SkiaSharp + + + + + + + + CPU + GPU + Drawing + + + + + @Body + + + + +@code { + bool _drawerOpen = true; + bool _isDarkMode; + ThemeMode _themeMode = ThemeMode.System; + MudThemeProvider _themeProvider = null!; + + readonly MudTheme _theme = new() + { + PaletteLight = new PaletteLight + { + Primary = "#594AE2", + AppbarBackground = "#594AE2", + DrawerBackground = "#f0eef6", + DrawerText = "#424242", + DrawerIcon = "#594AE2", + Background = "#fafafa", + Surface = "#ffffff", + }, + PaletteDark = new PaletteDark + { + Primary = "#776be7", + AppbarBackground = "#1e1e2d", + DrawerBackground = "#1a1a27", + Background = "#121218", + Surface = "#1e1e2d", + }, + LayoutProperties = new LayoutProperties + { + DefaultBorderRadius = "12px", + }, + }; + + string _themeIcon => _themeMode switch + { + ThemeMode.Light => Icons.Material.Filled.LightMode, + ThemeMode.Dark => Icons.Material.Filled.DarkMode, + _ => Icons.Material.Filled.SettingsBrightness, + }; + + string _themeTooltip => _themeMode switch + { + ThemeMode.Light => "Light mode (click for dark)", + ThemeMode.Dark => "Dark mode (click for system)", + _ => "System theme (click for light)", + }; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _isDarkMode = await _themeProvider.GetSystemDarkModeAsync(); + await _themeProvider.WatchSystemDarkModeAsync(OnSystemPreferenceChanged); + + var route = Program.DefaultPage switch + { + SamplePage.Gpu => "/gpu", + SamplePage.Drawing => "/drawing", + _ => null, + }; + if (route is not null) + Navigation.NavigateTo(route); + + StateHasChanged(); + } + } + + Task OnSystemPreferenceChanged(bool isDark) + { + if (_themeMode == ThemeMode.System) + { + _isDarkMode = isDark; + StateHasChanged(); + } + return Task.CompletedTask; + } + + async Task CycleTheme() + { + _themeMode = _themeMode switch + { + ThemeMode.System => ThemeMode.Light, + ThemeMode.Light => ThemeMode.Dark, + ThemeMode.Dark => ThemeMode.System, + _ => ThemeMode.System, + }; + + _isDarkMode = _themeMode switch + { + ThemeMode.Light => false, + ThemeMode.Dark => true, + _ => await _themeProvider.GetSystemDarkModeAsync(), + }; + + StateHasChanged(); + } + + void ToggleDrawer() => _drawerOpen = !_drawerOpen; + + enum ThemeMode { System, Light, Dark } +} diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/Pages/Drawing.razor b/samples/Basic/BlazorServer/SkiaSharpSample/Pages/Drawing.razor new file mode 100644 index 00000000000..16cc8216495 --- /dev/null +++ b/samples/Basic/BlazorServer/SkiaSharpSample/Pages/Drawing.razor @@ -0,0 +1,199 @@ +@page "/drawing" +@implements IDisposable + +@* Drawing Canvas Demo + Demonstrates SKCanvasView with pointer events, wheel delta for + brush size control, and basic stroke rendering. + Shows multi-stroke drawing with color picker and on-demand rendering + via manual Invalidate() (no render loop). *@ + +
+ + + + + +
+
+ @foreach (var (name, darkName, color, darkColor) in colors) + { + var c = _isDarkMode ? darkColor : color; + var css = _isDarkMode ? darkName : name; +
+ } +
+
+ + @($"{brushSize:F0}") +
+
+ +
+ +@code { + + [CascadingParameter(Name = "IsDarkMode")] + bool _isDarkMode { get; set; } + + bool _prevDarkMode; + SKCanvasView skiaView = null!; + float brushSize = 4f; + SKColor currentColor = SKColors.Black; + List strokes = []; + DrawingStroke? currentStroke = null; + SKPoint lastPoint; + + protected override void OnParametersSet() + { + if (_prevDarkMode != _isDarkMode) + { + _prevDarkMode = _isDarkMode; + // Swap the default color when toggling themes + if (_isDarkMode && currentColor == SKColors.Black) + currentColor = SKColors.White; + else if (!_isDarkMode && currentColor == SKColors.White) + currentColor = SKColors.Black; + skiaView?.Invalidate(); + } + } + + // The paint that is reused for drawing strokes. + readonly SKPaint strokePaint = new() + { + IsAntialias = true, + Style = SKPaintStyle.Stroke, + StrokeCap = SKStrokeCap.Round, + StrokeJoin = SKStrokeJoin.Round, + }; + + // Colors with light and dark variants — brighter in dark mode for visibility. + // Matches the Android Material 3 drawing palette. + static readonly (string Name, string DarkName, SKColor Color, SKColor DarkColor)[] colors = + { + ("#000000", "#FFFFFF", new SKColor(0x00, 0x00, 0x00), new SKColor(0xFF, 0xFF, 0xFF)), + ("#E53935", "#EF5350", new SKColor(0xE5, 0x39, 0x35), new SKColor(0xEF, 0x53, 0x50)), + ("#1E88E5", "#42A5F5", new SKColor(0x1E, 0x88, 0xE5), new SKColor(0x42, 0xA5, 0xF5)), + ("#43A047", "#66BB6A", new SKColor(0x43, 0xA0, 0x47), new SKColor(0x66, 0xBB, 0x6A)), + ("#FB8C00", "#FFA726", new SKColor(0xFB, 0x8C, 0x00), new SKColor(0xFF, 0xA7, 0x26)), + ("#8E24AA", "#AB47BC", new SKColor(0x8E, 0x24, 0xAA), new SKColor(0xAB, 0x47, 0xBC)), + }; + + SKColor CanvasBackground => _isDarkMode ? new SKColor(0x11, 0x13, 0x18) : SKColors.White; + + void OnPaintSurface(SKPaintSurfaceEventArgs e) + { + var canvas = e.Surface.Canvas; + + canvas.Clear(CanvasBackground); + + // Draw completed strokes. + foreach (var stroke in strokes) + { + strokePaint.Color = stroke.Color; + strokePaint.StrokeWidth = stroke.StrokeWidth; + canvas.DrawPath(stroke.Path, strokePaint); + } + + // Draw in-progress stroke. + if (currentStroke is not null) + { + strokePaint.Color = currentStroke.Color; + strokePaint.StrokeWidth = currentStroke.StrokeWidth; + canvas.DrawPath(currentStroke.Path, strokePaint); + } + + // Brush size indicator at last pointer position. + strokePaint.Color = SKColors.Gray; + strokePaint.StrokeWidth = 1; + canvas.DrawCircle(lastPoint, brushSize / 2f, strokePaint); + } + + void OnPointerDown(PointerEventArgs e) + { + if (!e.IsPrimary) + return; + lastPoint = new SKPoint((float)e.OffsetX, (float)e.OffsetY); + currentStroke = new DrawingStroke(new(), currentColor, brushSize); + currentStroke.Path.MoveTo(lastPoint); + skiaView.Invalidate(); + } + + void OnPointerMove(PointerEventArgs e) + { + if (!e.IsPrimary) + return; + lastPoint = new SKPoint((float)e.OffsetX, (float)e.OffsetY); + if (currentStroke is not null) + currentStroke.Path.LineTo(lastPoint); + skiaView.Invalidate(); + } + + void OnPointerUp(PointerEventArgs e) + { + if (!e.IsPrimary) + return; + if (currentStroke is not null) + { + strokes.Add(currentStroke); + currentStroke = null; + skiaView.Invalidate(); + } + } + + void OnWheel(WheelEventArgs e) + { + // DeltaY > 0 = scroll down = decrease brush size; DeltaY < 0 = scroll up = increase. + // Normalize ~100 CSS pixels per notch to ~1px brush change per notch. + brushSize -= (float)(e.DeltaY / 100.0); + brushSize = Math.Clamp(brushSize, 1f, 50f); + StateHasChanged(); + } + + void OnSliderInput(ChangeEventArgs e) + { + if (float.TryParse(e.Value?.ToString(), out var val)) + { + brushSize = val; + StateHasChanged(); + } + } + + void SetColor(SKColor color) + { + currentColor = color; + skiaView.Invalidate(); + StateHasChanged(); + } + + void ClearCanvas() + { + foreach (var stroke in strokes) + { + stroke.Path.Dispose(); + } + strokes.Clear(); + currentStroke?.Path.Dispose(); + currentStroke = null; + skiaView.Invalidate(); + StateHasChanged(); + } + + // Represents a single drawing stroke with its path, color, and width. + // Path is owned — callers must dispose when removing strokes. + record DrawingStroke(SKPath Path, SKColor Color, float StrokeWidth = 4); + + public void Dispose() + { + foreach (var stroke in strokes) + stroke.Path.Dispose(); + strokes.Clear(); + currentStroke?.Path.Dispose(); + currentStroke = null; + strokePaint?.Dispose(); + } +} diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/Pages/Drawing.razor.css b/samples/Basic/BlazorServer/SkiaSharpSample/Pages/Drawing.razor.css new file mode 100644 index 00000000000..0dbb459ac9d --- /dev/null +++ b/samples/Basic/BlazorServer/SkiaSharpSample/Pages/Drawing.razor.css @@ -0,0 +1,90 @@ +.canvas-container { + position: relative; + overflow: hidden; +} + +.clear-btn { + position: absolute; + top: 12px; + right: 12px; + z-index: 1; + background: var(--pill-bg); + border: 1px solid var(--pill-border); + border-radius: 24px; + padding: 6px 20px; + backdrop-filter: blur(8px); + color: var(--pill-text); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.clear-btn:hover { + background: rgba(0,0,0,0.85); +} + +.overlay-toolbar { + position: absolute; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + z-index: 1; + background: var(--pill-bg); + border: 1px solid var(--pill-border); + border-radius: 28px; + padding: 14px 24px; + backdrop-filter: blur(8px); +} + +.swatch-row { + display: flex; + gap: 14px; + align-items: center; +} + +.color-swatch { + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + border: 2px solid transparent; + transition: transform 0.2s, box-shadow 0.2s; +} + +.color-swatch:hover { + transform: scale(1.15); +} + +.color-swatch.selected { + transform: scale(1.3); + border-color: transparent; + box-shadow: 0 0 0 2px rgba(255,255,255,0.8); +} + +.slider-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; +} + +.brush-slider { + flex: 1; + min-width: 160px; + accent-color: white; + cursor: pointer; +} + +.size-label { + color: var(--pill-text); + font-size: 0.8rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + min-width: 24px; + text-align: right; +} diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/Pages/GPU.razor b/samples/Basic/BlazorServer/SkiaSharpSample/Pages/GPU.razor new file mode 100644 index 00000000000..579fd9741eb --- /dev/null +++ b/samples/Basic/BlazorServer/SkiaSharpSample/Pages/GPU.razor @@ -0,0 +1,200 @@ +@page "/gpu" +@implements IDisposable + +@* GPU Canvas Demo + Demonstrates SKGLView with EnableRenderLoop for continuous animation, + SKRuntimeEffect.BuildShader() for compiling SkSL shaders, and + touch interaction via uniforms. The entire visual is rendered in SkSL — + no C# draw calls. C# only handles touch input and passes uniforms. *@ + +
+ + + +
+ FPS: @($"{displayFps:F0}") +
+ +
+ +@code { + + // SkSL shader source: a "lava lamp" metaball effect. + // 6 colored blobs orbit on Lissajous curves. When the user touches, + // an extra white-hot blob appears at the touch position and merges + // with the others. All rendering is per-pixel in the shader. + const string sksl = @" + // Uniforms passed from C# each frame + uniform float iTime; // elapsed seconds + uniform float2 iResolution; // canvas size in pixels + uniform float2 iTouchPos; // touch position normalized 0..1 + uniform float iTouchActive; // 1.0 when touching, 0.0 otherwise + uniform float3 iColors[6]; // blob color palette + + half4 main(float2 fragCoord) { + // Convert pixel coords to normalized UV and aspect-corrected coords + float2 uv = fragCoord / iResolution; + float aspect = iResolution.x / iResolution.y; + float2 st = float2(uv.x * aspect, uv.y); + float t = iTime; + + // Metaball field: accumulate 1/r² contributions from each blob. + // 'weighted' tracks color contribution weighted by field strength. + float field = 0.0; + float3 weighted = float3(0.0); + + // Each blob orbits on a Lissajous curve with unique phase and speed + for (int i = 0; i < 6; i++) { + float fi = float(i); + float phase = fi * 1.047; // evenly spaced: 2*pi/6 + float speed = 0.3 + fi * 0.07; + + // Lissajous orbit center + float2 center = float2( + aspect * 0.5 + 0.4 * sin(t * speed + phase) * cos(t * speed * 0.6 + fi), + 0.5 + 0.4 * cos(t * speed * 0.8 + phase * 1.3) * sin(t * speed * 0.4 + fi * 0.7) + ); + + // Metaball field: strength falls off as 1/r², creating smooth merging + float2 d = st - center; + float r = length(d); + float strength = 0.030 / (r * r + 0.002); + field += strength; + weighted += iColors[i] * strength; + } + + // Touch interaction: add an extra, larger blob at the touch position + if (iTouchActive > 0.5) { + float2 touchSt = float2(iTouchPos.x * aspect, iTouchPos.y); + float2 d = st - touchSt; + float r = length(d); + float strength = 0.050 / (r * r + 0.002); + field += strength; + // white-hot touch blob + weighted += float3(1.0, 0.95, 0.9) * strength; + } + + // Normalize accumulated color by field strength for smooth blending + float3 blobColor = weighted / max(field, 0.001); + + // Threshold the field into blob edges with smooth falloff + float edge = smoothstep(5.0, 8.0, field); + float innerGlow = smoothstep(8.0, 20.0, field) * 0.3; + + // Subtly animated dark background + float3 bg = float3(0.03, 0.02, 0.08); + bg += float3(0.02, 0.01, 0.03) * sin(t * 0.2 + uv.y * 3.0); + + // Outer glow: visible between the halo and solid edge + float halo = smoothstep(3.0, 5.0, field) * (1.0 - edge); + + // Composite: background + halo + solid blobs with inner glow + float3 result = bg; + result += blobColor * halo * 0.4; + result = mix(result, blobColor * (1.0 + innerGlow), edge); + + // Vignette: darken corners for depth + float2 vc = uv - 0.5; + float vignette = 1.0 - dot(vc, vc) * 0.8; + result *= vignette; + + return half4(clamp(result, 0.0, 1.0), 1.0); + } + "; + + static readonly float[] blobColors = + { + 1.0f, 0.3f, 0.4f, // hot pink + 0.3f, 0.7f, 1.0f, // sky blue + 1.0f, 0.6f, 0.1f, // orange + 0.4f, 1.0f, 0.7f, // mint + 0.7f, 0.3f, 1.0f, // purple + 1.0f, 0.9f, 0.2f, // yellow + }; + + // SKRuntimeShaderBuilder: compiled once via BuildShader(), then reused. + // Each frame we update uniforms and call Build() to get a new SKShader. + Lazy shaderBuilder = new(() => SKRuntimeEffect.BuildShader(sksl)); + + readonly FpsCounter fpsCounter = new(); + + // The paint that is reused for drawing the shader quad. + readonly SKPaint shaderPaint = new(); + + // Touch state passed to the shader as uniforms. + SKPoint touchPos; + bool touchActive; + + // FPS display value, updated each sampling interval. + double displayFps; + + protected override void OnInitialized() + { + fpsCounter.Start(); + } + + // Handle pointer down: start tracking touch position. + void OnPointerDown(PointerEventArgs e) + { + if (!e.IsPrimary) + return; + touchPos = new SKPoint((float)e.OffsetX, (float)e.OffsetY); + touchActive = true; + } + + // Handle pointer move: update touch position while pointer is pressed. + void OnPointerMove(PointerEventArgs e) + { + if (!e.IsPrimary || e.Buttons == 0) + return; + touchPos = new SKPoint((float)e.OffsetX, (float)e.OffsetY); + } + + // Handle pointer up: clear touch state. + void OnPointerUp(PointerEventArgs e) + { + if (!e.IsPrimary) + return; + touchActive = false; + } + + // Called every frame by SKGLView's render loop. + void OnPaintSurface(SKPaintGLSurfaceEventArgs e) + { + var canvas = e.Surface.Canvas; + var width = e.Info.Width; + var height = e.Info.Height; + + // Update uniforms — no allocations, reuses the builder's internal storage. + var builder = shaderBuilder.Value; + builder.Uniforms["iTime"] = fpsCounter.ElapsedSeconds; + builder.Uniforms["iResolution"] = new float[] { width, height }; + builder.Uniforms["iTouchPos"] = new float[] { + touchActive ? touchPos.X / width : -1f, + touchActive ? touchPos.Y / height : -1f + }; + builder.Uniforms["iTouchActive"] = touchActive ? 1f : 0f; + builder.Uniforms["iColors"] = blobColors; + + // Build() creates a shader from cached effect + current uniforms. + // Draw a full-screen quad to run the shader on every pixel. + using var shader = builder.Build(); + shaderPaint.Shader = shader; + canvas.DrawRect(0, 0, width, height, shaderPaint); + + if (fpsCounter.Tick() is double fps) + { + displayFps = fps; + _ = InvokeAsync(StateHasChanged); + } + } + + public void Dispose() + { + if (shaderBuilder?.IsValueCreated == true) + shaderBuilder.Value.Dispose(); + shaderPaint?.Dispose(); + } +} diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/Pages/GPU.razor.css b/samples/Basic/BlazorServer/SkiaSharpSample/Pages/GPU.razor.css new file mode 100644 index 00000000000..6c31c601660 --- /dev/null +++ b/samples/Basic/BlazorServer/SkiaSharpSample/Pages/GPU.razor.css @@ -0,0 +1,20 @@ +.canvas-container { + position: relative; +} + +.overlay-stats { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 1; + background: var(--pill-bg); + border: 1px solid var(--pill-border); + border-radius: 24px; + padding: 6px 20px; + backdrop-filter: blur(8px); + color: var(--pill-text); + font-size: 0.85rem; + font-weight: 500; + font-variant-numeric: tabular-nums; +} diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/Pages/Home.razor b/samples/Basic/BlazorServer/SkiaSharpSample/Pages/Home.razor new file mode 100644 index 00000000000..bbbf9254aac --- /dev/null +++ b/samples/Basic/BlazorServer/SkiaSharpSample/Pages/Home.razor @@ -0,0 +1,85 @@ +@page "/" + +@inject HttpClient Http + +@* Home Page + Demonstrates basic SKCanvasView usage with gradients, shapes, and text. + Shows SKShader.CreateRadialGradient, DrawCircle, DrawText, and SKFont. *@ + +
+ + + +
+ +@code { + + static SKTypeface? notoSans; + + static readonly (float X, float Y, float R, SKColor Color)[] circles = + { + (0.20f, 0.30f, 0.10f, new SKColor(0xFF, 0x4D, 0x66, 0xCC)), + (0.75f, 0.25f, 0.08f, new SKColor(0x4D, 0xB3, 0xFF, 0xCC)), + (0.15f, 0.70f, 0.07f, new SKColor(0xFF, 0x99, 0x1A, 0xCC)), + (0.80f, 0.70f, 0.12f, new SKColor(0x66, 0xFF, 0xB3, 0xCC)), + (0.50f, 0.15f, 0.06f, new SKColor(0xB3, 0x4D, 0xFF, 0xCC)), + (0.40f, 0.80f, 0.09f, new SKColor(0xFF, 0xE6, 0x33, 0xCC)), + }; + + static readonly SKColor[] gradientColors = + { + new SKColor(0x44, 0x88, 0xFF), + new SKColor(0x88, 0x33, 0xCC), + }; + + protected override async Task OnInitializedAsync() + { + if (notoSans != null) + return; + + var fontBytes = await Http.GetByteArrayAsync("fonts/NotoSans-Regular.ttf"); + using var stream = new MemoryStream(fontBytes); + notoSans = SKTypeface.FromStream(stream); + } + + void OnPaintSurface(SKPaintSurfaceEventArgs e) + { + var canvas = e.Surface.Canvas; + var width = e.Info.Width; + var height = e.Info.Height; + var center = new SKPoint(width / 2f, height / 2f); + var radius = Math.Max(width, height) / 2f; + + canvas.Clear(SKColors.White); + + // Background gradient + using var shader = SKShader.CreateRadialGradient(center, radius, gradientColors, SKShaderTileMode.Clamp); + using var bgPaint = new SKPaint + { + IsAntialias = true, + Shader = shader, + }; + canvas.DrawRect(0, 0, width, height, bgPaint); + + // Circles + using var circlePaint = new SKPaint + { + IsAntialias = true, + Style = SKPaintStyle.Fill, + }; + foreach (var (x, y, r, color) in circles) + { + circlePaint.Color = color; + canvas.DrawCircle(x * width, y * height, r * Math.Min(width, height), circlePaint); + } + + // Centered text + using var textPaint = new SKPaint + { + Color = SKColors.White, + IsAntialias = true, + }; + using var font = new SKFont(notoSans, width * 0.12f); + canvas.DrawText("SkiaSharp", center.X, center.Y + font.Size / 3f, SKTextAlign.Center, font, textPaint); + } +} diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/Program.cs b/samples/Basic/BlazorServer/SkiaSharpSample/Program.cs new file mode 100644 index 00000000000..812f431f7dc --- /dev/null +++ b/samples/Basic/BlazorServer/SkiaSharpSample/Program.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Components; +using MudBlazor.Services; +using SkiaSharpSample; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +// Mirror the WebAssembly sample: an HttpClient scoped to the app root (used by the Home page +// to load the bundled font) and MudBlazor. +builder.Services.AddScoped(sp => +{ + var navigation = sp.GetRequiredService(); + return new HttpClient { BaseAddress = new Uri(navigation.BaseUri) }; +}); +builder.Services.AddMudServices(); + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); +} + +app.UseStaticFiles(); +app.UseAntiforgery(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); + +/// +/// Partial program class exposing the setting. +/// +public partial class Program +{ + public static SamplePage DefaultPage { get; set; } = SamplePage.Cpu; +} diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/Properties/launchSettings.json b/samples/Basic/BlazorServer/SkiaSharpSample/Properties/launchSettings.json new file mode 100644 index 00000000000..841adeaa299 --- /dev/null +++ b/samples/Basic/BlazorServer/SkiaSharpSample/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/Routes.razor b/samples/Basic/BlazorServer/SkiaSharpSample/Routes.razor new file mode 100644 index 00000000000..4bf73b756ea --- /dev/null +++ b/samples/Basic/BlazorServer/SkiaSharpSample/Routes.razor @@ -0,0 +1,12 @@ + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/SamplePage.cs b/samples/Basic/BlazorServer/SkiaSharpSample/SamplePage.cs new file mode 100644 index 00000000000..0948902a3d0 --- /dev/null +++ b/samples/Basic/BlazorServer/SkiaSharpSample/SamplePage.cs @@ -0,0 +1,11 @@ +namespace SkiaSharpSample; + +/// +/// The pages available in this sample. +/// +public enum SamplePage +{ + Cpu, + Gpu, + Drawing, +} diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/SkiaSharpSample.csproj b/samples/Basic/BlazorServer/SkiaSharpSample/SkiaSharpSample.csproj new file mode 100644 index 00000000000..f4d28bde309 --- /dev/null +++ b/samples/Basic/BlazorServer/SkiaSharpSample/SkiaSharpSample.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + SkiaSharpSample + + + + + + + + + + + + + + diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/_Imports.razor b/samples/Basic/BlazorServer/SkiaSharpSample/_Imports.razor new file mode 100644 index 00000000000..801fe9b0af2 --- /dev/null +++ b/samples/Basic/BlazorServer/SkiaSharpSample/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.JSInterop +@using SkiaSharp +@using SkiaSharp.Views.Blazor +@using MudBlazor +@using SkiaSharpSample +@using SkiaSharpSample.Layout diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/wwwroot/css/app.css b/samples/Basic/BlazorServer/SkiaSharpSample/wwwroot/css/app.css new file mode 100644 index 00000000000..67652808903 --- /dev/null +++ b/samples/Basic/BlazorServer/SkiaSharpSample/wwwroot/css/app.css @@ -0,0 +1,88 @@ +:root { + --pill-bg: rgba(0,0,0,0.75); + --pill-border: rgba(255,255,255,0.15); + --pill-text: #ffffff; +} + +html, body { + height: 100%; + margin: 0; + overflow: hidden; +} + +#app { + height: 100%; +} + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.loading-progress { + position: relative; + display: block; + width: 8rem; + height: 8rem; + margin: 20vh auto 1rem auto; +} + + .loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); + } + + .loading-progress circle:last-child { + stroke: #1b6ec2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; + } + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + + .loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); + } + +.canvas-container { + line-height: 1; + flex: 1; + min-height: 0; +} + + .canvas-container canvas { + width: 100%; + height: 100%; + } diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/wwwroot/favicon.ico b/samples/Basic/BlazorServer/SkiaSharpSample/wwwroot/favicon.ico new file mode 100644 index 00000000000..481d8e4c377 Binary files /dev/null and b/samples/Basic/BlazorServer/SkiaSharpSample/wwwroot/favicon.ico differ diff --git a/samples/Basic/BlazorServer/SkiaSharpSample/wwwroot/fonts/NotoSans-Regular.ttf b/samples/Basic/BlazorServer/SkiaSharpSample/wwwroot/fonts/NotoSans-Regular.ttf new file mode 100644 index 00000000000..4bac02f2f46 Binary files /dev/null and b/samples/Basic/BlazorServer/SkiaSharpSample/wwwroot/fonts/NotoSans-Regular.ttf differ diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/BridgedRenderer.cs b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/BridgedRenderer.cs new file mode 100644 index 00000000000..a9b94b96e1f --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/BridgedRenderer.cs @@ -0,0 +1,269 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace SkiaSharp.Views.Blazor.Internal; + +/// +/// The bridged rendering strategy shared by SKCanvasView and SKGLView (Blazor +/// Server, Hybrid and static SSR): it renders on the .NET side into an RGBA raster surface, +/// produces the transfer payload, and presents it to the browser via +/// . +/// +/// +/// The render loop matches the WebAssembly semantics (driven by EnableRenderLoop and +/// Invalidate()) and is protected by backpressure: the next frame is only produced after +/// the previous transfer completes, byte-identical frames are suppressed, and each iteration +/// yields to the host dispatcher. Payload production runs off the dispatcher so it does not stall +/// the UI thread on Blazor Hybrid. This object is the target of the JavaScript +/// DotNetObjectReference for size/DPI callbacks. +/// +internal sealed class BridgedRenderer : IRenderer +{ + private readonly IJSRuntime js; + private readonly bool isGL; + private readonly HostKind hostKind; + private readonly SKBlazorOptions options; + private readonly RenderPaintHandler paint; + + private SKHtmlCanvasBridgeInterop? bridge; + private DotNetObjectReference? selfRef; + + private byte[]? pixels; + private System.Runtime.InteropServices.GCHandle pixelsHandle; + private SKSizeI pixelSize; + private byte[]? lastFrame; + + private SKSize canvasSize; + private double dpi; + + private bool initialized; + private bool inFlight; + private bool requested; + private bool disposed; + + public BridgedRenderer( + IJSRuntime js, + bool isGL, + HostKind hostKind, + SKBlazorOptions options, + RenderPaintHandler paint) + { + this.js = js ?? throw new ArgumentNullException(nameof(js)); + this.isGL = isGL; + this.hostKind = hostKind; + this.options = options ?? SKBlazorOptions.Default; + this.paint = paint ?? throw new ArgumentNullException(nameof(paint)); + } + + public bool EnableRenderLoop { get; set; } + + public bool IgnorePixelScaling { get; set; } + + public SKBlazorTransferFormat? TransferFormat { get; set; } + + public int? Quality { get; set; } + + public double Dpi => dpi; + + public async Task InitializeAsync(ElementReference canvas) + { + bridge = new SKHtmlCanvasBridgeInterop(js, canvas); + await bridge.ImportAsync(); + + selfRef = DotNetObjectReference.Create(this); + + // Mark initialized before invoking the JS initializer: the JS side reports the initial + // metrics synchronously during initialize(), and that OnMetricsChanged callback can race + // back to .NET before this method returns. If we are not yet "initialized" the first + // render would be dropped and nothing would re-trigger it. + initialized = true; + await bridge.InitializeAsync(selfRef, isGL); + } + + /// Invoked by the bridge JavaScript when the element size or DPR changes. + [JSInvokable] + public Task OnMetricsChanged(double width, double height, double newDpi) + { + canvasSize = new SKSize((float)width, (float)height); + dpi = newDpi > 0 ? newDpi : 1; + Invalidate(); + return Task.CompletedTask; + } + + public void Invalidate() + { + _ = RenderLoopAsync(); + } + + private async Task RenderLoopAsync() + { + if (!initialized || disposed) + return; + + if (inFlight) + { + requested = true; + return; + } + + inFlight = true; + try + { + do + { + requested = false; + await RenderAndPresentAsync(); + + // Always yield to the host dispatcher each iteration. Transferring a frame awaits + // an interop round-trip, but a suppressed (byte-identical) frame performs no + // transfer; without this yield a continuous render loop over a static scene would + // spin synchronously and starve the dispatcher, preventing input, metrics and even + // disposal from being processed (so the loop could not stop). + await Task.Yield(); + } + while ((EnableRenderLoop || requested) && !disposed && initialized); + } + catch (Exception ex) + { + Console.Error.WriteLine("SkiaSharp bridged render error: " + ex); + } + finally + { + inFlight = false; + } + } + + private async Task RenderAndPresentAsync() + { + if (canvasSize.Width <= 0 || canvasSize.Height <= 0 || dpi <= 0) + return; + + var info = EnsureBuffer(out var unscaledSize); + if (info.Width <= 0 || info.Height <= 0) + return; + + var userVisibleSize = IgnorePixelScaling ? unscaledSize : info.Size; + + using (var surface = SKSurface.Create(info, pixelsHandle.AddrOfPinnedObject(), info.RowBytes)) + { + if (surface is null) + return; + + if (IgnorePixelScaling) + { + var canvas = surface.Canvas; + canvas.Scale((float)dpi); + canvas.Save(); + } + + PaintFrame(surface, info.WithSize(userVisibleSize), info); + } + + var format = Host.ResolveTransferFormat(hostKind, TransferFormat, options); + var quality = Quality ?? options.Quality; + + // Produce the transfer payload OFF the dispatcher. On Blazor Hybrid the render loop runs + // on the UI thread, so encoding (or copying a raw buffer for Put) here would stutter the + // app. The frame buffer is stable while we do this because backpressure prevents the next + // frame from starting (and therefore from reallocating/overwriting the buffer) until the + // present below completes. + var bufferPtr = pixelsHandle.AddrOfPinnedObject(); + var frameInfo = info; + var frame = await Task.Run(() => + { + using var image = SKImage.FromPixels(frameInfo, bufferPtr, frameInfo.RowBytes); + return FrameProducer.Produce(image, format, quality); + }); + + if (frame.Length == 0) + return; + + if (lastFrame != null && lastFrame.AsSpan().SequenceEqual(frame)) + return; + lastFrame = frame; + + if (bridge != null && !disposed) + await bridge.PresentAsync(frame, info.Width, info.Height, FormatToken(format), isGL); + } + + private void PaintFrame(SKSurface surface, SKImageInfo info, SKImageInfo rawInfo) + { + if (!isGL) + { + paint(surface, info, rawInfo, null, default); + return; + } + + // Server-side drawing is CPU raster; present into the WebGL-backed canvas. The render + // target is a lightweight placeholder so the GL event args shape is preserved. + using var placeholder = new GRBackendRenderTarget( + rawInfo.Width, + rawInfo.Height, + 0, + 0, + new GRGlFramebufferInfo(0, rawInfo.ColorType.ToGlSizedFormat())); + + paint(surface, info, rawInfo, placeholder, GRSurfaceOrigin.TopLeft); + } + + private SKImageInfo EnsureBuffer(out SKSizeI unscaledSize) + { + unscaledSize = SKSizeI.Empty; + + var w = canvasSize.Width; + var h = canvasSize.Height; + if (!IsPositive(w) || !IsPositive(h)) + return SKImageInfo.Empty; + + unscaledSize = new SKSizeI((int)w, (int)h); + var scaled = new SKSizeI((int)(w * dpi), (int)(h * dpi)); + var info = new SKImageInfo(scaled.Width, scaled.Height, FrameProducer.RgbaColorType, SKAlphaType.Premul); + + if (pixels == null || pixelSize.Width != info.Width || pixelSize.Height != info.Height) + { + FreeBuffer(); + pixels = new byte[info.BytesSize]; + pixelsHandle = System.Runtime.InteropServices.GCHandle.Alloc(pixels, System.Runtime.InteropServices.GCHandleType.Pinned); + pixelSize = info.Size; + } + + return info; + + static bool IsPositive(double value) => + !double.IsNaN(value) && !double.IsInfinity(value) && value > 0; + } + + private void FreeBuffer() + { + if (pixels != null) + { + pixelsHandle.Free(); + pixels = null; + } + } + + private static string FormatToken(SKBlazorTransferFormat format) => format switch + { + SKBlazorTransferFormat.Png => "png", + SKBlazorTransferFormat.Jpeg => "jpeg", + _ => "put", + }; + + public async ValueTask DisposeAsync() + { + disposed = true; + + if (bridge != null) + { + await bridge.DisposeAsync(); + bridge = null; + } + + selfRef?.Dispose(); + selfRef = null; + + FreeBuffer(); + } +} diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/CanvasDirectRenderer.cs b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/CanvasDirectRenderer.cs new file mode 100644 index 00000000000..f591655bacb --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/CanvasDirectRenderer.cs @@ -0,0 +1,155 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace SkiaSharp.Views.Blazor.Internal; + +/// +/// The direct (Blazor WebAssembly) rendering strategy for SKCanvasView: SkiaSharp draws in +/// the browser into a raster surface and the pixels are written straight into the HTML canvas via +/// putImageData. The render loop is driven by the browser's requestAnimationFrame. +/// +[SupportedOSPlatform("browser")] +internal sealed class CanvasDirectRenderer : IRenderer +{ + private readonly IJSRuntime js; + private readonly RenderPaintHandler paint; + + private SKHtmlCanvasInterop interop = null!; + private SizeWatcherInterop sizeWatcher = null!; + private DpiWatcherInterop dpiWatcher = null!; + private ElementReference htmlCanvas; + + private SKSizeI pixelSize; + private byte[]? pixels; + private GCHandle pixelsHandle; + private double dpi; + private SKSize canvasSize; + + public CanvasDirectRenderer(IJSRuntime js, RenderPaintHandler paint) + { + this.js = js; + this.paint = paint; + } + + public bool EnableRenderLoop { get; set; } + + public bool IgnorePixelScaling { get; set; } + + // Transfer settings only apply to the bridged path; ignored here. + public SKBlazorTransferFormat? TransferFormat { get; set; } + + public int? Quality { get; set; } + + public double Dpi => dpi; + + public async Task InitializeAsync(ElementReference canvas) + { + htmlCanvas = canvas; + + interop = await SKHtmlCanvasInterop.ImportAsync(js, htmlCanvas, OnRenderFrame); + interop.InitRaster(); + + sizeWatcher = await SizeWatcherInterop.ImportAsync(js, htmlCanvas, OnSizeChanged); + dpiWatcher = await DpiWatcherInterop.ImportAsync(js, OnDpiChanged); + } + + public void Invalidate() + { + if (canvasSize.Width <= 0 || canvasSize.Height <= 0 || dpi <= 0) + return; + + interop.RequestAnimationFrame(EnableRenderLoop, (int)(canvasSize.Width * dpi), (int)(canvasSize.Height * dpi)); + } + + private void OnRenderFrame() + { + if (canvasSize.Width <= 0 || canvasSize.Height <= 0 || dpi <= 0) + return; + + var info = CreateBitmap(out var unscaledSize); + var userVisibleSize = IgnorePixelScaling ? unscaledSize : info.Size; + + using (var surface = SKSurface.Create(info, pixelsHandle.AddrOfPinnedObject(), info.RowBytes)) + { + if (IgnorePixelScaling) + { + var canvas = surface.Canvas; + canvas.Scale((float)dpi); + canvas.Save(); + } + + paint(surface, info.WithSize(userVisibleSize), info, null, default); + } + + interop.PutImageData(pixelsHandle.AddrOfPinnedObject(), info.Size); + } + + private SKImageInfo CreateBitmap(out SKSizeI unscaledSize) + { + var size = CreateSize(out unscaledSize); + var info = new SKImageInfo(size.Width, size.Height, SKImageInfo.PlatformColorType, SKAlphaType.Opaque); + + if (pixels == null || pixelSize.Width != info.Width || pixelSize.Height != info.Height) + { + FreeBitmap(); + + pixels = new byte[info.BytesSize]; + pixelsHandle = GCHandle.Alloc(pixels, GCHandleType.Pinned); + pixelSize = info.Size; + } + + return info; + } + + private SKSizeI CreateSize(out SKSizeI unscaledSize) + { + unscaledSize = SKSizeI.Empty; + + var w = canvasSize.Width; + var h = canvasSize.Height; + + if (!IsPositive(w) || !IsPositive(h)) + return SKSizeI.Empty; + + unscaledSize = new SKSizeI((int)w, (int)h); + return new SKSizeI((int)(w * dpi), (int)(h * dpi)); + + static bool IsPositive(double value) => + !double.IsNaN(value) && !double.IsInfinity(value) && value > 0; + } + + private void FreeBitmap() + { + if (pixels != null) + { + pixelsHandle.Free(); + pixels = null; + } + } + + private void OnDpiChanged(double newDpi) + { + dpi = newDpi; + Invalidate(); + } + + private void OnSizeChanged(SKSize newSize) + { + if ((int)(canvasSize.Width * dpi) == newSize.Width && (int)(canvasSize.Height * dpi) == newSize.Height) + return; + canvasSize = newSize; + Invalidate(); + } + + public ValueTask DisposeAsync() + { + dpiWatcher?.Unsubscribe(OnDpiChanged); + sizeWatcher?.Dispose(); + interop?.Dispose(); + FreeBitmap(); + return ValueTask.CompletedTask; + } +} diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/FrameProducer.cs b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/FrameProducer.cs new file mode 100644 index 00000000000..c0923d5e487 --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/FrameProducer.cs @@ -0,0 +1,88 @@ +using System; +using System.Runtime.InteropServices; + +namespace SkiaSharp.Views.Blazor.Internal; + +/// +/// Turns a rendered into the byte payload that is transferred to the +/// browser for a bridged view. Pure and side-effect free so it can be unit tested without a +/// browser or a Blazor host. +/// +/// +/// Bridged views render into an RGBA surface, so the +/// path produces bytes that are directly usable by both ImageData/putImageData +/// and WebGL texImage2D without any channel swapping. +/// +internal static class FrameProducer +{ + public const string PngContentType = "image/png"; + public const string JpegContentType = "image/jpeg"; + public const string RawContentType = "application/octet-stream"; + + /// The color type bridged views render into (directly browser-compatible). + public static readonly SKColorType RgbaColorType = SKColorType.Rgba8888; + + /// Returns the JavaScript-facing content type for a transfer format. + public static string GetContentType(SKBlazorTransferFormat format) => format switch + { + SKBlazorTransferFormat.Png => PngContentType, + SKBlazorTransferFormat.Jpeg => JpegContentType, + _ => RawContentType, + }; + + /// Produces the transfer payload for a frame in the requested format. + /// The rendered frame. Expected to be RGBA raster-backed. + /// The transfer format. + /// JPEG quality (0-100); ignored for other formats. + public static byte[] Produce(SKImage image, SKBlazorTransferFormat format, int quality) + { + if (image == null) + throw new ArgumentNullException(nameof(image)); + + switch (format) + { + case SKBlazorTransferFormat.Png: + return Encode(image, SKEncodedImageFormat.Png, 100); + case SKBlazorTransferFormat.Jpeg: + return Encode(image, SKEncodedImageFormat.Jpeg, ClampQuality(quality)); + case SKBlazorTransferFormat.Put: + default: + return ReadRawRgba(image); + } + } + + private static byte[] Encode(SKImage image, SKEncodedImageFormat format, int quality) + { + using var data = image.Encode(format, quality); + return data?.ToArray() ?? Array.Empty(); + } + + private static byte[] ReadRawRgba(SKImage image) + { + var info = new SKImageInfo(image.Width, image.Height, RgbaColorType, SKAlphaType.Unpremul); + var buffer = new byte[info.BytesSize]; + if (buffer.Length == 0) + return buffer; + + var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned); + try + { + if (!image.ReadPixels(info, handle.AddrOfPinnedObject(), info.RowBytes, 0, 0)) + return Array.Empty(); + return buffer; + } + finally + { + handle.Free(); + } + } + + private static int ClampQuality(int quality) + { + if (quality < 0) + return 0; + if (quality > 100) + return 100; + return quality; + } +} diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/GLDirectRenderer.cs b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/GLDirectRenderer.cs new file mode 100644 index 00000000000..f8468a6b698 --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/GLDirectRenderer.cs @@ -0,0 +1,178 @@ +using System.Runtime.Versioning; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace SkiaSharp.Views.Blazor.Internal; + +/// +/// The direct (Blazor WebAssembly) rendering strategy for SKGLView: SkiaSharp draws through +/// a real WebGL context in the browser, backed by a and a +/// wrapping the canvas framebuffer. The render loop is driven +/// by the browser's requestAnimationFrame. +/// +[SupportedOSPlatform("browser")] +internal sealed class GLDirectRenderer : IRenderer +{ + private const int ResourceCacheBytes = 256 * 1024 * 1024; // 256 MB + private const SKColorType ColorType = SKColorType.Rgba8888; + private const GRSurfaceOrigin SurfaceOrigin = GRSurfaceOrigin.BottomLeft; + + private readonly IJSRuntime js; + private readonly RenderPaintHandler paint; + + private SKHtmlCanvasInterop interop = null!; + private SizeWatcherInterop sizeWatcher = null!; + private DpiWatcherInterop dpiWatcher = null!; + private SKHtmlCanvasInterop.GLInfo jsGLInfo = null!; + private ElementReference htmlCanvas; + + private GRContext? context; + private GRGlInterface? glInterface; + private GRBackendRenderTarget? renderTarget; + private SKSize renderTargetSize; + private SKSurface? surface; + private SKCanvas? canvas; + private double dpi; + private SKSize canvasSize; + + public GLDirectRenderer(IJSRuntime js, RenderPaintHandler paint) + { + this.js = js; + this.paint = paint; + } + + public bool EnableRenderLoop { get; set; } + + public bool IgnorePixelScaling { get; set; } + + // Transfer settings only apply to the bridged path; ignored here. + public SKBlazorTransferFormat? TransferFormat { get; set; } + + public int? Quality { get; set; } + + public double Dpi => dpi; + + public async Task InitializeAsync(ElementReference canvas) + { + htmlCanvas = canvas; + + interop = await SKHtmlCanvasInterop.ImportAsync(js, htmlCanvas, OnRenderFrame); + jsGLInfo = interop.InitGL(); + + sizeWatcher = await SizeWatcherInterop.ImportAsync(js, htmlCanvas, OnSizeChanged); + dpiWatcher = await DpiWatcherInterop.ImportAsync(js, OnDpiChanged); + } + + public void Invalidate() + { + if (canvasSize.Width <= 0 || canvasSize.Height <= 0 || dpi <= 0 || jsGLInfo == null) + return; + + interop.RequestAnimationFrame(EnableRenderLoop, (int)(canvasSize.Width * dpi), (int)(canvasSize.Height * dpi)); + } + + private void OnRenderFrame() + { + if (canvasSize.Width <= 0 || canvasSize.Height <= 0 || dpi <= 0 || jsGLInfo == null) + return; + + // create the SkiaSharp context + if (context == null) + { + glInterface = GRGlInterface.Create(); + context = GRContext.CreateGl(glInterface); + + // bump the default resource cache limit + context.SetResourceCacheLimit(ResourceCacheBytes); + } + + // get the new surface size + var newSize = CreateSize(out var unscaledSize); + var info = new SKImageInfo(newSize.Width, newSize.Height, ColorType); + var userVisibleSize = IgnorePixelScaling ? unscaledSize : info.Size; + + // manage the drawing surface + if (renderTarget == null || renderTargetSize != newSize || !renderTarget.IsValid) + { + // create or update the dimensions + renderTargetSize = newSize; + + var glInfo = new GRGlFramebufferInfo(jsGLInfo.FboId, ColorType.ToGlSizedFormat()); + + // destroy the old surface + surface?.Dispose(); + surface = null; + canvas = null; + + // re-create the render target + renderTarget?.Dispose(); + renderTarget = new GRBackendRenderTarget(newSize.Width, newSize.Height, jsGLInfo.Samples, jsGLInfo.Stencils, glInfo); + } + + // create the surface + if (surface == null) + { + surface = SKSurface.Create(context, renderTarget, SurfaceOrigin, ColorType); + canvas = surface.Canvas; + } + + using (new SKAutoCanvasRestore(canvas, true)) + { + if (IgnorePixelScaling) + { + canvas!.Scale((float)dpi); + canvas.Save(); + } + + paint(surface, info.WithSize(userVisibleSize), info, renderTarget, SurfaceOrigin); + } + + // update the control + canvas?.Flush(); + context.Flush(); + } + + private SKSizeI CreateSize(out SKSizeI unscaledSize) + { + unscaledSize = SKSizeI.Empty; + + var w = canvasSize.Width; + var h = canvasSize.Height; + + if (!IsPositive(w) || !IsPositive(h)) + return SKSizeI.Empty; + + unscaledSize = new SKSizeI((int)w, (int)h); + return new SKSizeI((int)(w * dpi), (int)(h * dpi)); + + static bool IsPositive(double value) => + !double.IsNaN(value) && !double.IsInfinity(value) && value > 0; + } + + private void OnDpiChanged(double newDpi) + { + dpi = newDpi; + Invalidate(); + } + + private void OnSizeChanged(SKSize newSize) + { + canvasSize = newSize; + Invalidate(); + } + + public ValueTask DisposeAsync() + { + dpiWatcher?.Unsubscribe(OnDpiChanged); + sizeWatcher?.Dispose(); + interop?.Dispose(); + + surface?.Dispose(); + renderTarget?.Dispose(); + context?.Dispose(); + glInterface?.Dispose(); + + return ValueTask.CompletedTask; + } +} diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/Host.cs b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/Host.cs new file mode 100644 index 00000000000..a063ac69001 --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/Host.cs @@ -0,0 +1,84 @@ +using System; + +namespace SkiaSharp.Views.Blazor.Internal; + +/// +/// The kind of Blazor host a view is executing in. Determines whether a view draws directly +/// in the browser (WebAssembly) or renders on the .NET side and bridges frames to the browser. +/// +internal enum HostKind +{ + /// Unknown / not yet interactive. + Unknown, + + /// Blazor WebAssembly: draws directly in the browser (the "direct" path). + WebAssembly, + + /// Blazor Server: renders on the server, streams frames over SignalR. + Server, + + /// Blazor Hybrid (BlazorWebView): renders natively in-process. + Hybrid, + + /// Static server-side rendering / prerender: a single poster frame, no interactivity. + StaticSsr, +} + +internal static class Host +{ + /// + /// Maps a Blazor RendererInfo.Name to a host kind. Host detection is only consulted + /// once a component is interactive, so any unrecognized non-browser name is treated as a + /// native WebView (Blazor Hybrid); only the browser falls back to WebAssembly. + /// + public static HostKind Resolve(string? rendererName) + { + switch (rendererName) + { + case "WebAssembly": + return HostKind.WebAssembly; + case "Server": + return HostKind.Server; + case "WebView": + return HostKind.Hybrid; + case "Static": + return HostKind.StaticSsr; + default: + return OperatingSystem.IsBrowser() + ? HostKind.WebAssembly + : HostKind.Hybrid; + } + } + + /// + /// Whether the host renders on the .NET side and must bridge frames to the browser + /// (Server, Hybrid or static SSR) rather than drawing directly in the browser. + /// + public static bool IsBridged(HostKind kind) => + kind == HostKind.Server || + kind == HostKind.Hybrid || + kind == HostKind.StaticSsr; + + /// + /// Resolves the effective transfer format: an explicit per-control value wins, then the + /// global option, then a host-independent JPEG default. + /// + public static SKBlazorTransferFormat ResolveTransferFormat( + HostKind kind, + SKBlazorTransferFormat? perControl, + SKBlazorOptions options) + { + if (perControl.HasValue) + return perControl.Value; + + if (options.TransferFormat.HasValue) + return options.TransferFormat.Value; + + // JPEG by default for every bridged host. Keeping the per-frame payload small matters + // even for Blazor Hybrid: the WebView bridge marshals the bytes across the native/JS + // boundary rather than sharing memory, so pushing a raw full-resolution buffer every + // frame (SKBlazorTransferFormat.Put) is far heavier than a small encoded frame. Apps + // can still opt into Put (lossless, no encode) or Png (lossless with alpha). + return SKBlazorTransferFormat.Jpeg; + } +} diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/IRenderer.cs b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/IRenderer.cs new file mode 100644 index 00000000000..8e8d6d4e320 --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/IRenderer.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; + +namespace SkiaSharp.Views.Blazor.Internal; + +/// +/// Paints one frame into the supplied surface. and +/// are only meaningful for SKGLView (they are +/// /default for SKCanvasView); the component uses them to build the +/// appropriate paint event args. +/// +internal delegate void RenderPaintHandler( + SKSurface surface, + SKImageInfo info, + SKImageInfo rawInfo, + GRBackendRenderTarget? glRenderTarget, + GRSurfaceOrigin glOrigin); + +/// +/// A rendering strategy for a Blazor SkiaSharp view. Concrete implementations are the in-browser +/// direct renderers (, ) and the +/// bridged renderer (). The component owns one instance and drives it +/// through this interface, so it does not need to branch on the host. +/// +internal interface IRenderer : IAsyncDisposable +{ + /// The device pixel ratio currently in effect. + double Dpi { get; } + + /// Whether frames are produced continuously. + bool EnableRenderLoop { get; set; } + + /// The coordinate space handed to the paint callback (see the view's IgnorePixelScaling). + bool IgnorePixelScaling { get; set; } + + /// Bridged-only transfer format override; ignored by direct renderers. + SKBlazorTransferFormat? TransferFormat { get; set; } + + /// Bridged-only JPEG quality override; ignored by direct renderers. + int? Quality { get; set; } + + /// Initializes the renderer against the view's canvas element. + Task InitializeAsync(ElementReference canvas); + + /// Requests that a frame be rendered. + void Invalidate(); +} diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/SKHtmlCanvasBridgeInterop.cs b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/SKHtmlCanvasBridgeInterop.cs new file mode 100644 index 00000000000..01f3d98a3e0 --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Internal/SKHtmlCanvasBridgeInterop.cs @@ -0,0 +1,75 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace SkiaSharp.Views.Blazor.Internal; + +/// +/// Host-agnostic interop for the bridged presentation module (SKHtmlCanvasBridge.js). +/// Unlike this uses the standard +/// pipeline, so it works over the Blazor Server circuit and inside a Blazor Hybrid WebView — +/// anywhere that is not WebAssembly. +/// +internal sealed class SKHtmlCanvasBridgeInterop : IAsyncDisposable +{ + private const string JsFilename = "./_content/SkiaSharp.Views.Blazor/SKHtmlCanvasBridge.js"; + + private readonly IJSRuntime js; + private readonly ElementReference canvas; + private IJSObjectReference? module; + + public SKHtmlCanvasBridgeInterop(IJSRuntime js, ElementReference canvas) + { + this.js = js ?? throw new ArgumentNullException(nameof(js)); + this.canvas = canvas; + } + + public async Task ImportAsync() + { + module = await js.InvokeAsync("import", JsFilename); + } + + public async Task InitializeAsync(DotNetObjectReference dotNetRef, bool isGL) + where T : class + { + if (module is null) + return; + + await module.InvokeVoidAsync("initialize", canvas, dotNetRef, isGL); + } + + public async Task PresentAsync(byte[] bytes, int width, int height, string format, bool isGL) + { + if (module is null) + return; + + await module.InvokeVoidAsync("present", canvas, bytes, width, height, format, isGL); + } + + public async ValueTask DisposeAsync() + { + if (module is null) + return; + + try + { + await module.InvokeVoidAsync("deinit", canvas); + } + catch + { + // circuit may already be gone + } + + try + { + await module.DisposeAsync(); + } + catch + { + // no-op + } + + module = null; + } +} diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Properties/AssemblyInfo.cs b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Properties/AssemblyInfo.cs new file mode 100644 index 00000000000..7d22f52896f --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/Properties/AssemblyInfo.cs @@ -0,0 +1,8 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("SkiaSharp.Tests.Blazor, PublicKey=" + + "002400000480000094000000060200000024000052534131000400000100010079159977d2d03a" + + "8e6bea7a2e74e8d1afcc93e8851974952bb480a12c9134474d04062447c37e0e68c080536fcf3c" + + "3fbe2ff9c979ce998475e506e8ce82dd5b0f350dc10e93bf2eeecf874b24770c5081dbea7447fd" + + "dafa277b22de47d6ffea449674a4f9fccf84d15069089380284dbdd35f46cdff12a1bd78e4ef00" + + "65d016df")] diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKBlazorOptions.cs b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKBlazorOptions.cs new file mode 100644 index 00000000000..5eb281ca284 --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKBlazorOptions.cs @@ -0,0 +1,25 @@ +namespace SkiaSharp.Views.Blazor; + +/// +/// Global defaults for SkiaSharp Blazor views, applied when a view running in a bridged +/// host (Blazor Server, Blazor Hybrid or static server-side rendering) does not specify its +/// own values. Register with services.AddSkiaSharpViewsBlazor(...). +/// +public sealed class SKBlazorOptions +{ + internal static readonly SKBlazorOptions Default = new SKBlazorOptions(); + + /// + /// The default frame transfer format. When (the default) every + /// bridged host uses , which keeps the per-frame + /// payload small for both the Blazor Server network transport and the Blazor Hybrid WebView + /// bridge. + /// + public SKBlazorTransferFormat? TransferFormat { get; set; } + + /// + /// The JPEG quality (0-100) used when is + /// selected. Ignored for other formats. Defaults to 85. + /// + public int Quality { get; set; } = 85; +} diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKBlazorServiceCollectionExtensions.cs b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKBlazorServiceCollectionExtensions.cs new file mode 100644 index 00000000000..c2cea3b29e8 --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKBlazorServiceCollectionExtensions.cs @@ -0,0 +1,33 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace SkiaSharp.Views.Blazor; + +/// +/// Dependency-injection helpers for SkiaSharp Blazor views. +/// +public static class SKBlazorServiceCollectionExtensions +{ + /// + /// Registers global defaults for SkiaSharp Blazor views. Calling this is optional; when + /// it is not called, sensible per-host defaults are used. + /// + /// The service collection. + /// An optional callback to configure the defaults. + /// The service collection for chaining. + public static IServiceCollection AddSkiaSharpViewsBlazor( + this IServiceCollection services, + Action? configure = null) + { + if (services == null) + throw new ArgumentNullException(nameof(services)); + + var options = new SKBlazorOptions(); + configure?.Invoke(options); + + services.TryAddSingleton(options); + + return services; + } +} diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKBlazorTransferFormat.cs b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKBlazorTransferFormat.cs new file mode 100644 index 00000000000..fc87df757f9 --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKBlazorTransferFormat.cs @@ -0,0 +1,33 @@ +namespace SkiaSharp.Views.Blazor; + +/// +/// Controls how a rendered frame is transferred to the browser canvas when a view is +/// running in a bridged host (Blazor Server, Blazor Hybrid or static server-side rendering). +/// +/// +/// This setting only applies when the view cannot draw directly in the browser (that is, +/// anywhere other than Blazor WebAssembly). In WebAssembly the frame is drawn natively and +/// this value is ignored. +/// +public enum SKBlazorTransferFormat +{ + /// + /// Encode each frame as a PNG image. Lossless and preserves transparency, but produces + /// larger payloads. A good choice when transparency is required. + /// + Png, + + /// + /// Encode each frame as a JPEG image. Small payloads suitable for streaming over a + /// network, but does not preserve transparency. This is the default for every bridged host. + /// + Jpeg, + + /// + /// Transfer the raw pixel buffer with no encoding. Avoids encode/decode cost but produces + /// the largest payloads; it is not recommended over a network (Blazor Server) and, because + /// the Blazor Hybrid WebView bridge marshals the bytes rather than sharing memory, it is not + /// recommended there either. + /// + Put, +} diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKCanvasView.razor.cs b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKCanvasView.razor.cs index 388ccc94d07..b94681e5381 100644 --- a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKCanvasView.razor.cs +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKCanvasView.razor.cs @@ -1,6 +1,5 @@ -using System; +using System; using System.Collections.Generic; -using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Threading.Tasks; using Microsoft.AspNetCore.Components; @@ -9,25 +8,40 @@ namespace SkiaSharp.Views.Blazor { +#if !NET9_0_OR_GREATER [SupportedOSPlatform("browser")] +#endif public partial class SKCanvasView : IDisposable { - private SKHtmlCanvasInterop interop = null!; - private SizeWatcherInterop sizeWatcher = null!; - private DpiWatcherInterop dpiWatcher = null!; private ElementReference htmlCanvas; - - private SKSizeI pixelSize; - private byte[]? pixels; - private GCHandle pixelsHandle; + private IRenderer? renderer; private bool ignorePixelScaling; - private double dpi; - private SKSize canvasSize; private bool enableRenderLoop; [Inject] IJSRuntime JS { get; set; } = null!; +#if NET9_0_OR_GREATER + [Inject] + IServiceProvider Services { get; set; } = null!; + + /// + /// The frame transfer format to use when this view runs in a bridged host (Blazor Server, + /// Blazor Hybrid or static SSR). When the global option or a host + /// default is used. Ignored in Blazor WebAssembly. + /// + [Parameter] + public SKBlazorTransferFormat? TransferFormat { get; set; } + + /// + /// The JPEG quality (0-100) used when the resolved is + /// . When the global option + /// value is used. Ignored in Blazor WebAssembly. + /// + [Parameter] + public int? Quality { get; set; } +#endif + [Parameter] public Action? OnPaintSurface { get; set; } @@ -62,119 +76,67 @@ public bool IgnorePixelScaling [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary? AdditionalAttributes { get; set; } - public double Dpi => dpi; + public double Dpi => renderer?.Dpi ?? 0; protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender) - { - interop = await SKHtmlCanvasInterop.ImportAsync(JS, htmlCanvas, OnRenderFrame); - interop.InitRaster(); - - sizeWatcher = await SizeWatcherInterop.ImportAsync(JS, htmlCanvas, OnSizeChanged); - dpiWatcher = await DpiWatcherInterop.ImportAsync(JS, OnDpiChanged); - } - } - - public void Invalidate() - { - if (canvasSize.Width <= 0 || canvasSize.Height <= 0 || dpi <= 0) - return; - - interop.RequestAnimationFrame(EnableRenderLoop, (int)(canvasSize.Width * dpi), (int)(canvasSize.Height * dpi)); - } - - private void OnRenderFrame() - { - if (canvasSize.Width <= 0 || canvasSize.Height <= 0 || dpi <= 0) + if (!firstRender) return; - var info = CreateBitmap(out var unscaledSize); - var userVisibleSize = IgnorePixelScaling ? unscaledSize : info.Size; - - using (var surface = SKSurface.Create(info, pixelsHandle.AddrOfPinnedObject(), info.RowBytes)) +#if NET9_0_OR_GREATER + var hostKind = Host.Resolve(RendererInfo.Name); + if (Host.IsBridged(hostKind)) { - if (IgnorePixelScaling) - { - var canvas = surface.Canvas; - canvas.Scale((float)dpi); - canvas.Save(); - } - - OnPaintSurface?.Invoke(new SKPaintSurfaceEventArgs(surface, info.WithSize(userVisibleSize), info)); + var options = (Services?.GetService(typeof(SKBlazorOptions)) as SKBlazorOptions) ?? SKBlazorOptions.Default; + renderer = new BridgedRenderer(JS, isGL: false, hostKind, options, PaintFrame); } - - interop.PutImageData(pixelsHandle.AddrOfPinnedObject(), info.Size); - } - - private SKImageInfo CreateBitmap(out SKSizeI unscaledSize) - { - var size = CreateSize(out unscaledSize); - var info = new SKImageInfo(size.Width, size.Height, SKImageInfo.PlatformColorType, SKAlphaType.Opaque); - - if (pixels == null || pixelSize.Width != info.Width || pixelSize.Height != info.Height) + else if (OperatingSystem.IsBrowser()) { - FreeBitmap(); - - pixels = new byte[info.BytesSize]; - pixelsHandle = GCHandle.Alloc(pixels, GCHandleType.Pinned); - pixelSize = info.Size; + renderer = new CanvasDirectRenderer(JS, PaintFrame); } +#else + renderer = new CanvasDirectRenderer(JS, PaintFrame); +#endif - return info; - } - - private SKSizeI CreateSize(out SKSizeI unscaledSize) - { - unscaledSize = SKSizeI.Empty; - - var w = canvasSize.Width; - var h = canvasSize.Height; - - if (!IsPositive(w) || !IsPositive(h)) - return SKSizeI.Empty; - - unscaledSize = new SKSizeI((int)w, (int)h); - return new SKSizeI((int)(w * dpi), (int)(h * dpi)); - - static bool IsPositive(double value) - { - return !double.IsNaN(value) && !double.IsInfinity(value) && value > 0; - } - } + if (renderer == null) + return; - private void FreeBitmap() - { - if (pixels != null) - { - pixelsHandle.Free(); - pixels = null; - } + SyncRenderer(); + await renderer.InitializeAsync(htmlCanvas); } - private void OnDpiChanged(double newDpi) + public void Invalidate() { - dpi = newDpi; + if (renderer == null) + return; - Invalidate(); + SyncRenderer(); + renderer.Invalidate(); } - private void OnSizeChanged(SKSize newSize) + private void SyncRenderer() { - if ((int)(canvasSize.Width * dpi) == newSize.Width && (int)(canvasSize.Height * dpi) == newSize.Height) + if (renderer == null) return; - canvasSize = newSize; - Invalidate(); + renderer.EnableRenderLoop = enableRenderLoop; + renderer.IgnorePixelScaling = ignorePixelScaling; +#if NET9_0_OR_GREATER + renderer.TransferFormat = TransferFormat; + renderer.Quality = Quality; +#endif } + private void PaintFrame(SKSurface surface, SKImageInfo info, SKImageInfo rawInfo, GRBackendRenderTarget? glRenderTarget, GRSurfaceOrigin glOrigin) => + OnPaintSurface?.Invoke(new SKPaintSurfaceEventArgs(surface, info, rawInfo)); + public void Dispose() { - dpiWatcher?.Unsubscribe(OnDpiChanged); - sizeWatcher?.Dispose(); - interop?.Dispose(); - - FreeBitmap(); + if (renderer != null) + { + _ = renderer.DisposeAsync(); + renderer = null; + } } } } diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKGLView.razor.cs b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKGLView.razor.cs index fdf68d18d26..6bd9bd951ed 100644 --- a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKGLView.razor.cs +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/SKGLView.razor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Runtime.Versioning; using System.Threading.Tasks; @@ -8,33 +8,41 @@ namespace SkiaSharp.Views.Blazor { +#if !NET9_0_OR_GREATER [SupportedOSPlatform("browser")] +#endif public partial class SKGLView : IDisposable { - private SKHtmlCanvasInterop interop = null!; - private SizeWatcherInterop sizeWatcher = null!; - private DpiWatcherInterop dpiWatcher = null!; - private SKHtmlCanvasInterop.GLInfo jsGLInfo = null!; private ElementReference htmlCanvas; - - private const int ResourceCacheBytes = 256 * 1024 * 1024; // 256 MB - private const SKColorType colorType = SKColorType.Rgba8888; - private const GRSurfaceOrigin surfaceOrigin = GRSurfaceOrigin.BottomLeft; - - private GRContext? context; - private GRGlInterface? glInterface; - private GRBackendRenderTarget? renderTarget; - private SKSize renderTargetSize; - private SKSurface? surface; - private SKCanvas? canvas; - private bool enableRenderLoop; + private IRenderer? renderer; private bool ignorePixelScaling; - private double dpi; - private SKSize canvasSize; + private bool enableRenderLoop; [Inject] IJSRuntime JS { get; set; } = null!; +#if NET9_0_OR_GREATER + [Inject] + IServiceProvider Services { get; set; } = null!; + + /// + /// The frame transfer format to use when this view runs in a bridged host (Blazor Server, + /// Blazor Hybrid or static SSR). When the global option or a host + /// default is used. Ignored in Blazor WebAssembly. In a bridged host drawing is CPU raster + /// and the frame is presented into a WebGL-backed canvas. + /// + [Parameter] + public SKBlazorTransferFormat? TransferFormat { get; set; } + + /// + /// The JPEG quality (0-100) used when the resolved is + /// . When the global option + /// value is used. Ignored in Blazor WebAssembly. + /// + [Parameter] + public int? Quality { get; set; } +#endif + [Parameter] public Action? OnPaintSurface { get; set; } @@ -69,129 +77,67 @@ public bool IgnorePixelScaling [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary? AdditionalAttributes { get; set; } - public double Dpi => dpi; + public double Dpi => renderer?.Dpi ?? 0; protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender) - { - interop = await SKHtmlCanvasInterop.ImportAsync(JS, htmlCanvas, OnRenderFrame); - jsGLInfo = interop.InitGL(); - - sizeWatcher = await SizeWatcherInterop.ImportAsync(JS, htmlCanvas, OnSizeChanged); - dpiWatcher = await DpiWatcherInterop.ImportAsync(JS, OnDpiChanged); - } - } - - public void Invalidate() - { - if (canvasSize.Width <= 0 || canvasSize.Height <= 0 || dpi <= 0 || jsGLInfo == null) - return; - - interop.RequestAnimationFrame(EnableRenderLoop, (int)(canvasSize.Width * dpi), (int)(canvasSize.Height * dpi)); - } - - private void OnRenderFrame() - { - if (canvasSize.Width <= 0 || canvasSize.Height <= 0 || dpi <= 0 || jsGLInfo == null) + if (!firstRender) return; - // create the SkiaSharp context - if (context == null) +#if NET9_0_OR_GREATER + var hostKind = Host.Resolve(RendererInfo.Name); + if (Host.IsBridged(hostKind)) { - glInterface = GRGlInterface.Create(); - context = GRContext.CreateGl(glInterface); - - // bump the default resource cache limit - context.SetResourceCacheLimit(ResourceCacheBytes); + var options = (Services?.GetService(typeof(SKBlazorOptions)) as SKBlazorOptions) ?? SKBlazorOptions.Default; + renderer = new BridgedRenderer(JS, isGL: true, hostKind, options, PaintFrame); } - - // get the new surface size - var newSize = CreateSize(out var unscaledSize); - var info = new SKImageInfo(newSize.Width, newSize.Height, colorType); - var userVisibleSize = IgnorePixelScaling ? unscaledSize : info.Size; - - // manage the drawing surface - if (renderTarget == null || renderTargetSize != newSize || !renderTarget.IsValid) + else if (OperatingSystem.IsBrowser()) { - // create or update the dimensions - renderTargetSize = newSize; - - var glInfo = new GRGlFramebufferInfo(jsGLInfo.FboId, colorType.ToGlSizedFormat()); - - // destroy the old surface - surface?.Dispose(); - surface = null; - canvas = null; - - // re-create the render target - renderTarget?.Dispose(); - renderTarget = new GRBackendRenderTarget(newSize.Width, newSize.Height, jsGLInfo.Samples, jsGLInfo.Stencils, glInfo); - } - - // create the surface - if (surface == null) - { - surface = SKSurface.Create(context, renderTarget, surfaceOrigin, colorType); - canvas = surface.Canvas; + renderer = new GLDirectRenderer(JS, PaintFrame); } +#else + renderer = new GLDirectRenderer(JS, PaintFrame); +#endif - using (new SKAutoCanvasRestore(canvas, true)) - { - if (IgnorePixelScaling) - { - var canvas = surface.Canvas; - canvas.Scale((float)dpi); - canvas.Save(); - } - - // start drawing - OnPaintSurface?.Invoke(new SKPaintGLSurfaceEventArgs(surface, renderTarget, surfaceOrigin, info.WithSize(userVisibleSize), info)); - } + if (renderer == null) + return; - // update the control - canvas?.Flush(); - context.Flush(); + SyncRenderer(); + await renderer.InitializeAsync(htmlCanvas); } - private void OnDpiChanged(double newDpi) + public void Invalidate() { - dpi = newDpi; + if (renderer == null) + return; - Invalidate(); + SyncRenderer(); + renderer.Invalidate(); } - private void OnSizeChanged(SKSize newSize) + private void SyncRenderer() { - canvasSize = newSize; + if (renderer == null) + return; - Invalidate(); + renderer.EnableRenderLoop = enableRenderLoop; + renderer.IgnorePixelScaling = ignorePixelScaling; +#if NET9_0_OR_GREATER + renderer.TransferFormat = TransferFormat; + renderer.Quality = Quality; +#endif } - private SKSizeI CreateSize(out SKSizeI unscaledSize) - { - unscaledSize = SKSizeI.Empty; - - var w = canvasSize.Width; - var h = canvasSize.Height; - - if (!IsPositive(w) || !IsPositive(h)) - return SKSizeI.Empty; - - unscaledSize = new SKSizeI((int)w, (int)h); - return new SKSizeI((int)(w * dpi), (int)(h * dpi)); - - static bool IsPositive(double value) - { - return !double.IsNaN(value) && !double.IsInfinity(value) && value > 0; - } - } + private void PaintFrame(SKSurface surface, SKImageInfo info, SKImageInfo rawInfo, GRBackendRenderTarget? glRenderTarget, GRSurfaceOrigin glOrigin) => + OnPaintSurface?.Invoke(new SKPaintGLSurfaceEventArgs(surface, glRenderTarget!, glOrigin, info, rawInfo)); public void Dispose() { - dpiWatcher.Unsubscribe(OnDpiChanged); - sizeWatcher.Dispose(); - interop.Dispose(); + if (renderer != null) + { + _ = renderer.DisposeAsync(); + renderer = null; + } } } } diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKCanvasPresenter.js b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKCanvasPresenter.js new file mode 100644 index 00000000000..5148376512b --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKCanvasPresenter.js @@ -0,0 +1,155 @@ +// Shared, pure-browser canvas presentation primitives ("render steps"). +// +// This module contains no emscripten and no .NET-callback dependencies, so it is safe to +// import in every Blazor host (WebAssembly, Server, Hybrid, static SSR). Both the WebAssembly +// direct path (SKHtmlCanvas) and the bridged path (SKHtmlCanvasBridge) funnel their final +// paint through here so the pixel-to-canvas logic is not duplicated. +const glStates = new WeakMap(); +export function sizeCanvas(canvas, width, height) { + if (!canvas || width <= 0 || height <= 0) + return; + if (canvas.width !== width) + canvas.width = width; + if (canvas.height !== height) + canvas.height = height; +} +export function present2DPixels(canvas, bytes, width, height) { + if (!canvas || !bytes || width <= 0 || height <= 0) + return false; + const ctx = canvas.getContext('2d'); + if (!ctx) + return false; + sizeCanvas(canvas, width, height); + const buffer = bytes.buffer; + const clamped = new Uint8ClampedArray(buffer, bytes.byteOffset, bytes.byteLength); + const imageData = new ImageData(clamped, width, height); + ctx.putImageData(imageData, 0, 0); + return true; +} +export function present2DBitmap(canvas, bitmap, width, height) { + if (!canvas || !bitmap) + return false; + const ctx = canvas.getContext('2d'); + if (!ctx) + return false; + sizeCanvas(canvas, width || bitmap.width, height || bitmap.height); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(bitmap, 0, 0); + return true; +} +const VERTEX_SHADER = `#version 300 es +in vec2 a_position; +in vec2 a_texCoord; +out vec2 v_texCoord; +void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = a_texCoord; +}`; +const FRAGMENT_SHADER = `#version 300 es +precision mediump float; +in vec2 v_texCoord; +uniform sampler2D u_image; +out vec4 outColor; +void main() { + outColor = texture(u_image, v_texCoord); +}`; +function compileShader(gl, type, source) { + const shader = gl.createShader(type); + if (!shader) + return null; + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.error('SKCanvasPresenter shader error: ' + gl.getShaderInfoLog(shader)); + gl.deleteShader(shader); + return null; + } + return shader; +} +function ensureGLState(canvas) { + let state = glStates.get(canvas); + if (state) + return state; + const gl = canvas.getContext('webgl2'); + if (!gl) + return null; + const vs = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); + if (!vs || !fs) + return null; + const program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.error('SKCanvasPresenter link error: ' + gl.getProgramInfoLog(program)); + return null; + } + const positionLocation = gl.getAttribLocation(program, 'a_position'); + const texCoordLocation = gl.getAttribLocation(program, 'a_texCoord'); + // two triangles covering the viewport, with matching (flipped) texture coords + const positionBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + // x, y, u, v + -1, -1, 0, 1, + 1, -1, 1, 1, + -1, 1, 0, 0, + -1, 1, 0, 0, + 1, -1, 1, 1, + 1, 1, 1, 0, + ]), gl.STATIC_DRAW); + const texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + state = { gl, program, texture, positionBuffer, positionLocation, texCoordLocation }; + glStates.set(canvas, state); + return state; +} +function drawGL(state, canvas) { + const gl = state.gl; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.useProgram(state.program); + gl.bindBuffer(gl.ARRAY_BUFFER, state.positionBuffer); + gl.enableVertexAttribArray(state.positionLocation); + gl.vertexAttribPointer(state.positionLocation, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(state.texCoordLocation); + gl.vertexAttribPointer(state.texCoordLocation, 2, gl.FLOAT, false, 16, 8); + gl.drawArrays(gl.TRIANGLES, 0, 6); +} +export function presentGLPixels(canvas, bytes, width, height) { + if (!canvas || !bytes || width <= 0 || height <= 0) + return false; + sizeCanvas(canvas, width, height); + const state = ensureGLState(canvas); + if (!state) + return false; + const gl = state.gl; + const pixels = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + gl.bindTexture(gl.TEXTURE_2D, state.texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + drawGL(state, canvas); + return true; +} +export function presentGLBitmap(canvas, bitmap, width, height) { + if (!canvas || !bitmap) + return false; + sizeCanvas(canvas, width || bitmap.width, height || bitmap.height); + const state = ensureGLState(canvas); + if (!state) + return false; + const gl = state.gl; + gl.bindTexture(gl.TEXTURE_2D, state.texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + drawGL(state, canvas); + return true; +} +export function disposePresenter(canvas) { + glStates.delete(canvas); +} +//# sourceMappingURL=SKCanvasPresenter.js.map \ No newline at end of file diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKCanvasPresenter.js.map b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKCanvasPresenter.js.map new file mode 100644 index 00000000000..05851dce5c4 --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKCanvasPresenter.js.map @@ -0,0 +1 @@ +{"version":3,"file":"SKCanvasPresenter.js","sourceRoot":"","sources":["SKCanvasPresenter.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKCanvasPresenter.ts b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKCanvasPresenter.ts new file mode 100644 index 00000000000..903bf737e12 --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKCanvasPresenter.ts @@ -0,0 +1,193 @@ +// Shared, pure-browser canvas presentation primitives ("render steps"). +// +// This module contains no emscripten and no .NET-callback dependencies, so it is safe to +// import in every Blazor host (WebAssembly, Server, Hybrid, static SSR). Both the WebAssembly +// direct path (SKHtmlCanvas) and the bridged path (SKHtmlCanvasBridge) funnel their final +// paint through here so the pixel-to-canvas logic is not duplicated. + +type GLBlitState = { + gl: WebGL2RenderingContext | WebGLRenderingContext; + program: WebGLProgram; + texture: WebGLTexture; + positionBuffer: WebGLBuffer; + positionLocation: number; + texCoordLocation: number; +}; + +const glStates = new WeakMap(); + +export function sizeCanvas(canvas: HTMLCanvasElement, width: number, height: number): void { + if (!canvas || width <= 0 || height <= 0) + return; + if (canvas.width !== width) + canvas.width = width; + if (canvas.height !== height) + canvas.height = height; +} + +export function present2DPixels(canvas: HTMLCanvasElement, bytes: Uint8Array, width: number, height: number): boolean { + if (!canvas || !bytes || width <= 0 || height <= 0) + return false; + + const ctx = canvas.getContext('2d'); + if (!ctx) + return false; + + sizeCanvas(canvas, width, height); + + const buffer = bytes.buffer as ArrayBuffer; + const clamped = new Uint8ClampedArray(buffer, bytes.byteOffset, bytes.byteLength); + const imageData = new ImageData(clamped, width, height); + ctx.putImageData(imageData, 0, 0); + return true; +} + +export function present2DBitmap(canvas: HTMLCanvasElement, bitmap: ImageBitmap, width: number, height: number): boolean { + if (!canvas || !bitmap) + return false; + + const ctx = canvas.getContext('2d'); + if (!ctx) + return false; + + sizeCanvas(canvas, width || bitmap.width, height || bitmap.height); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(bitmap, 0, 0); + return true; +} + +const VERTEX_SHADER = `#version 300 es +in vec2 a_position; +in vec2 a_texCoord; +out vec2 v_texCoord; +void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = a_texCoord; +}`; + +const FRAGMENT_SHADER = `#version 300 es +precision mediump float; +in vec2 v_texCoord; +uniform sampler2D u_image; +out vec4 outColor; +void main() { + outColor = texture(u_image, v_texCoord); +}`; + +function compileShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null { + const shader = gl.createShader(type); + if (!shader) + return null; + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.error('SKCanvasPresenter shader error: ' + gl.getShaderInfoLog(shader)); + gl.deleteShader(shader); + return null; + } + return shader; +} + +function ensureGLState(canvas: HTMLCanvasElement): GLBlitState | null { + let state = glStates.get(canvas); + if (state) + return state; + + const gl = canvas.getContext('webgl2') as WebGL2RenderingContext; + if (!gl) + return null; + + const vs = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); + if (!vs || !fs) + return null; + + const program = gl.createProgram()!; + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.error('SKCanvasPresenter link error: ' + gl.getProgramInfoLog(program)); + return null; + } + + const positionLocation = gl.getAttribLocation(program, 'a_position'); + const texCoordLocation = gl.getAttribLocation(program, 'a_texCoord'); + + // two triangles covering the viewport, with matching (flipped) texture coords + const positionBuffer = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + // x, y, u, v + -1, -1, 0, 1, + 1, -1, 1, 1, + -1, 1, 0, 0, + -1, 1, 0, 0, + 1, -1, 1, 1, + 1, 1, 1, 0, + ]), gl.STATIC_DRAW); + + const texture = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + + state = { gl, program, texture, positionBuffer, positionLocation, texCoordLocation }; + glStates.set(canvas, state); + return state; +} + +function drawGL(state: GLBlitState, canvas: HTMLCanvasElement): void { + const gl = state.gl; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + + gl.useProgram(state.program); + gl.bindBuffer(gl.ARRAY_BUFFER, state.positionBuffer); + gl.enableVertexAttribArray(state.positionLocation); + gl.vertexAttribPointer(state.positionLocation, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(state.texCoordLocation); + gl.vertexAttribPointer(state.texCoordLocation, 2, gl.FLOAT, false, 16, 8); + + gl.drawArrays(gl.TRIANGLES, 0, 6); +} + +export function presentGLPixels(canvas: HTMLCanvasElement, bytes: Uint8Array, width: number, height: number): boolean { + if (!canvas || !bytes || width <= 0 || height <= 0) + return false; + + sizeCanvas(canvas, width, height); + const state = ensureGLState(canvas); + if (!state) + return false; + + const gl = state.gl; + const pixels = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + gl.bindTexture(gl.TEXTURE_2D, state.texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + drawGL(state, canvas); + return true; +} + +export function presentGLBitmap(canvas: HTMLCanvasElement, bitmap: ImageBitmap, width: number, height: number): boolean { + if (!canvas || !bitmap) + return false; + + sizeCanvas(canvas, width || bitmap.width, height || bitmap.height); + const state = ensureGLState(canvas); + if (!state) + return false; + + const gl = state.gl; + gl.bindTexture(gl.TEXTURE_2D, state.texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + drawGL(state, canvas); + return true; +} + +export function disposePresenter(canvas: HTMLCanvasElement): void { + glStates.delete(canvas); +} diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKHtmlCanvasBridge.js b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKHtmlCanvasBridge.js new file mode 100644 index 00000000000..d899530d784 --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKHtmlCanvasBridge.js @@ -0,0 +1,86 @@ +// Bridged presentation for Blazor Server / Hybrid / static SSR. +// +// Unlike SKHtmlCanvas (the WebAssembly direct path) this module has NO emscripten dependency, +// so it can run in a Blazor Server client browser or a Hybrid WebView where SkiaSharp runs on +// the .NET side and only the encoded/raw frame bytes arrive here. All painting is delegated to +// the shared SKCanvasPresenter primitives. +import * as Presenter from './SKCanvasPresenter.js'; +const states = new WeakMap(); +function measure(canvas) { + return { + width: Math.max(0, Math.round(canvas.clientWidth || 0)), + height: Math.max(0, Math.round(canvas.clientHeight || 0)), + dpr: window.devicePixelRatio || 1, + }; +} +function reportMetrics(canvas, state, force) { + const m = measure(canvas); + if (!force && m.width === state.lastWidth && m.height === state.lastHeight && Math.abs(m.dpr - state.lastDpr) < 0.001) + return; + state.lastWidth = m.width; + state.lastHeight = m.height; + state.lastDpr = m.dpr; + if (state.dotNetRef) + state.dotNetRef.invokeMethodAsync('OnMetricsChanged', m.width, m.height, m.dpr); +} +export function initialize(canvas, dotNetRef, isGL) { + if (!canvas) + return; + deinit(canvas); + const state = { + dotNetRef, + isGL: !!isGL, + lastWidth: -1, + lastHeight: -1, + lastDpr: -1, + }; + states.set(canvas, state); + if (typeof ResizeObserver === 'function') { + state.resizeObserver = new ResizeObserver(() => reportMetrics(canvas, state, false)); + state.resizeObserver.observe(canvas); + } + // devicePixelRatio has no event; poll like the existing DpiWatcher. + state.dpiTimer = window.setInterval(() => reportMetrics(canvas, state, false), 1000); + reportMetrics(canvas, state, true); +} +export function getMetrics(canvas) { + return measure(canvas); +} +export function present(canvas, bytes, width, height, format, isGL) { + if (!canvas || !bytes || width <= 0 || height <= 0) + return; + const raw = format === 'put'; + if (raw) { + const pixels = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + if (isGL) + Presenter.presentGLPixels(canvas, pixels, width, height); + else + Presenter.present2DPixels(canvas, pixels, width, height); + return; + } + const type = format === 'png' ? 'image/png' : 'image/jpeg'; + const blob = new Blob([bytes], { type }); + return createImageBitmap(blob).then(bitmap => { + try { + if (isGL) + Presenter.presentGLBitmap(canvas, bitmap, width, height); + else + Presenter.present2DBitmap(canvas, bitmap, width, height); + } + finally { + bitmap.close(); + } + }).catch(err => console.error('SKHtmlCanvasBridge present error: ' + err)); +} +export function deinit(canvas) { + const state = states.get(canvas); + if (!state) + return; + if (state.resizeObserver) + state.resizeObserver.disconnect(); + if (state.dpiTimer) + window.clearInterval(state.dpiTimer); + Presenter.disposePresenter(canvas); + states.delete(canvas); +} +//# sourceMappingURL=SKHtmlCanvasBridge.js.map \ No newline at end of file diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKHtmlCanvasBridge.js.map b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKHtmlCanvasBridge.js.map new file mode 100644 index 00000000000..ddd5f410602 --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKHtmlCanvasBridge.js.map @@ -0,0 +1 @@ +{"version":3,"file":"SKHtmlCanvasBridge.js","sourceRoot":"","sources":["SKHtmlCanvasBridge.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKHtmlCanvasBridge.ts b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKHtmlCanvasBridge.ts new file mode 100644 index 00000000000..ba91aa8ff7c --- /dev/null +++ b/source/SkiaSharp.Views/SkiaSharp.Views.Blazor/wwwroot/SKHtmlCanvasBridge.ts @@ -0,0 +1,122 @@ +// Bridged presentation for Blazor Server / Hybrid / static SSR. +// +// Unlike SKHtmlCanvas (the WebAssembly direct path) this module has NO emscripten dependency, +// so it can run in a Blazor Server client browser or a Hybrid WebView where SkiaSharp runs on +// the .NET side and only the encoded/raw frame bytes arrive here. All painting is delegated to +// the shared SKCanvasPresenter primitives. + +import * as Presenter from './SKCanvasPresenter.js'; + +type DotNetRef = { invokeMethodAsync: (method: string, ...args: any[]) => Promise; }; + +type BridgeState = { + dotNetRef: DotNetRef; + isGL: boolean; + resizeObserver?: ResizeObserver; + dpiTimer?: number; + lastWidth: number; + lastHeight: number; + lastDpr: number; +}; + +const states = new WeakMap(); + +function measure(canvas: HTMLCanvasElement) { + return { + width: Math.max(0, Math.round(canvas.clientWidth || 0)), + height: Math.max(0, Math.round(canvas.clientHeight || 0)), + dpr: window.devicePixelRatio || 1, + }; +} + +function reportMetrics(canvas: HTMLCanvasElement, state: BridgeState, force: boolean): void { + const m = measure(canvas); + if (!force && m.width === state.lastWidth && m.height === state.lastHeight && Math.abs(m.dpr - state.lastDpr) < 0.001) + return; + + state.lastWidth = m.width; + state.lastHeight = m.height; + state.lastDpr = m.dpr; + + if (state.dotNetRef) + state.dotNetRef.invokeMethodAsync('OnMetricsChanged', m.width, m.height, m.dpr); +} + +export function initialize(canvas: HTMLCanvasElement, dotNetRef: DotNetRef, isGL: boolean): void { + if (!canvas) + return; + + deinit(canvas); + + const state: BridgeState = { + dotNetRef, + isGL: !!isGL, + lastWidth: -1, + lastHeight: -1, + lastDpr: -1, + }; + states.set(canvas, state); + + if (typeof ResizeObserver === 'function') { + state.resizeObserver = new ResizeObserver(() => reportMetrics(canvas, state, false)); + state.resizeObserver.observe(canvas); + } + + // devicePixelRatio has no event; poll like the existing DpiWatcher. + state.dpiTimer = window.setInterval(() => reportMetrics(canvas, state, false), 1000); + + reportMetrics(canvas, state, true); +} + +export function getMetrics(canvas: HTMLCanvasElement) { + return measure(canvas); +} + +export function present( + canvas: HTMLCanvasElement, + bytes: Uint8Array, + width: number, + height: number, + format: string, + isGL: boolean): void | Promise { + + if (!canvas || !bytes || width <= 0 || height <= 0) + return; + + const raw = format === 'put'; + if (raw) { + const pixels = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + if (isGL) + Presenter.presentGLPixels(canvas, pixels, width, height); + else + Presenter.present2DPixels(canvas, pixels, width, height); + return; + } + + const type = format === 'png' ? 'image/png' : 'image/jpeg'; + const blob = new Blob([bytes as BlobPart], { type }); + return createImageBitmap(blob).then(bitmap => { + try { + if (isGL) + Presenter.presentGLBitmap(canvas, bitmap, width, height); + else + Presenter.present2DBitmap(canvas, bitmap, width, height); + } finally { + bitmap.close(); + } + }).catch(err => console.error('SKHtmlCanvasBridge present error: ' + err)); +} + +export function deinit(canvas: HTMLCanvasElement): void { + const state = states.get(canvas); + if (!state) + return; + + if (state.resizeObserver) + state.resizeObserver.disconnect(); + if (state.dpiTimer) + window.clearInterval(state.dpiTimer); + + Presenter.disposePresenter(canvas); + states.delete(canvas); +} diff --git a/tests/SkiaSharp.Tests.Blazor/FrameProducerTests.cs b/tests/SkiaSharp.Tests.Blazor/FrameProducerTests.cs new file mode 100644 index 00000000000..e6d8abd3379 --- /dev/null +++ b/tests/SkiaSharp.Tests.Blazor/FrameProducerTests.cs @@ -0,0 +1,87 @@ +using SkiaSharp; +using SkiaSharp.Views.Blazor; +using SkiaSharp.Views.Blazor.Internal; +using Xunit; + +namespace SkiaSharp.Tests.Blazor; + +public class FrameProducerTests +{ + private static SKImage CreateImage(int width = 8, int height = 8, SKColor? color = null) + { + var info = new SKImageInfo(width, height, FrameProducer.RgbaColorType, SKAlphaType.Premul); + using var surface = SKSurface.Create(info); + surface.Canvas.Clear(color ?? SKColors.Red); + return surface.Snapshot(); + } + + [Fact] + public void PngProducesPngSignature() + { + using var image = CreateImage(); + + var bytes = FrameProducer.Produce(image, SKBlazorTransferFormat.Png, 100); + + Assert.True(bytes.Length > 8); + // PNG signature: 89 50 4E 47 + Assert.Equal(0x89, bytes[0]); + Assert.Equal(0x50, bytes[1]); + Assert.Equal(0x4E, bytes[2]); + Assert.Equal(0x47, bytes[3]); + Assert.Equal("image/png", FrameProducer.GetContentType(SKBlazorTransferFormat.Png)); + } + + [Fact] + public void JpegProducesJpegSignature() + { + using var image = CreateImage(); + + var bytes = FrameProducer.Produce(image, SKBlazorTransferFormat.Jpeg, 80); + + Assert.True(bytes.Length > 3); + // JPEG start-of-image marker: FF D8 + Assert.Equal(0xFF, bytes[0]); + Assert.Equal(0xD8, bytes[1]); + Assert.Equal("image/jpeg", FrameProducer.GetContentType(SKBlazorTransferFormat.Jpeg)); + } + + [Fact] + public void PutProducesRawRgbaOfExpectedLength() + { + using var image = CreateImage(4, 3, SKColors.Red); + + var bytes = FrameProducer.Produce(image, SKBlazorTransferFormat.Put, 0); + + Assert.Equal(4 * 3 * 4, bytes.Length); + // first pixel is opaque red in RGBA order (directly usable by ImageData/texImage2D) + Assert.Equal(255, bytes[0]); + Assert.Equal(0, bytes[1]); + Assert.Equal(0, bytes[2]); + Assert.Equal(255, bytes[3]); + Assert.Equal("application/octet-stream", FrameProducer.GetContentType(SKBlazorTransferFormat.Put)); + } + + [Fact] + public void PutPreservesTransparency() + { + using var image = CreateImage(2, 2, SKColors.Transparent); + + var bytes = FrameProducer.Produce(image, SKBlazorTransferFormat.Put, 0); + + // fully transparent => alpha 0 + Assert.Equal(0, bytes[3]); + } + + [Theory] + [InlineData(-50)] + [InlineData(50)] + [InlineData(500)] + public void JpegQualityIsClampedAndAlwaysProducesData(int quality) + { + using var image = CreateImage(); + + var bytes = FrameProducer.Produce(image, SKBlazorTransferFormat.Jpeg, quality); + + Assert.NotEmpty(bytes); + } +} diff --git a/tests/SkiaSharp.Tests.Blazor/HostTests.cs b/tests/SkiaSharp.Tests.Blazor/HostTests.cs new file mode 100644 index 00000000000..208b2174085 --- /dev/null +++ b/tests/SkiaSharp.Tests.Blazor/HostTests.cs @@ -0,0 +1,65 @@ +using SkiaSharp.Views.Blazor; +using SkiaSharp.Views.Blazor.Internal; +using Xunit; + +namespace SkiaSharp.Tests.Blazor; + +public class HostTests +{ + [Theory] + [InlineData("WebAssembly", "WebAssembly")] + [InlineData("Server", "Server")] + [InlineData("WebView", "Hybrid")] + [InlineData("Static", "StaticSsr")] + public void ResolvesKnownRendererNames(string name, string expected) => + Assert.Equal(expected, Host.Resolve(name).ToString()); + + [Fact] + public void ResolvesUnknownRendererNameOffBrowserToHybrid() => + // Host detection is only consulted once interactive; an unrecognized non-browser host + // is treated as a native WebView (Hybrid) so the bridged path still activates. + Assert.Equal(HostKind.Hybrid, Host.Resolve("something-else")); + + [Theory] + [InlineData("Server", true)] + [InlineData("WebView", true)] + [InlineData("Static", true)] + [InlineData("WebAssembly", false)] + public void IsBridgedIsCorrect(string rendererName, bool expected) => + Assert.Equal(expected, Host.IsBridged(Host.Resolve(rendererName))); + + [Fact] + public void PerControlFormatWinsOverGlobalAndHostDefault() + { + var options = new SKBlazorOptions { TransferFormat = SKBlazorTransferFormat.Png }; + + var resolved = Host.ResolveTransferFormat( + HostKind.Hybrid, + SKBlazorTransferFormat.Jpeg, + options); + + Assert.Equal(SKBlazorTransferFormat.Jpeg, resolved); + } + + [Fact] + public void GlobalOptionUsedWhenNoPerControlValue() + { + var options = new SKBlazorOptions { TransferFormat = SKBlazorTransferFormat.Png }; + + var resolved = Host.ResolveTransferFormat(HostKind.Server, null, options); + + Assert.Equal(SKBlazorTransferFormat.Png, resolved); + } + + [Fact] + public void HybridDefaultsToJpeg() => + Assert.Equal( + SKBlazorTransferFormat.Jpeg, + Host.ResolveTransferFormat(HostKind.Hybrid, null, new SKBlazorOptions())); + + [Fact] + public void ServerDefaultsToJpeg() => + Assert.Equal( + SKBlazorTransferFormat.Jpeg, + Host.ResolveTransferFormat(HostKind.Server, null, new SKBlazorOptions())); +} diff --git a/tests/SkiaSharp.Tests.Blazor/SKBlazorServiceCollectionExtensionsTests.cs b/tests/SkiaSharp.Tests.Blazor/SKBlazorServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000000..2682be595b1 --- /dev/null +++ b/tests/SkiaSharp.Tests.Blazor/SKBlazorServiceCollectionExtensionsTests.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.DependencyInjection; +using SkiaSharp.Views.Blazor; +using Xunit; + +namespace SkiaSharp.Tests.Blazor; + +public class SKBlazorServiceCollectionExtensionsTests +{ + [Fact] + public void AddRegistersConfiguredOptions() + { + var services = new ServiceCollection(); + + services.AddSkiaSharpViewsBlazor(o => + { + o.TransferFormat = SKBlazorTransferFormat.Png; + o.Quality = 42; + }); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetService(); + + Assert.NotNull(options); + Assert.Equal(SKBlazorTransferFormat.Png, options!.TransferFormat); + Assert.Equal(42, options.Quality); + } + + [Fact] + public void AddWithoutConfigureUsesDefaults() + { + var services = new ServiceCollection(); + + services.AddSkiaSharpViewsBlazor(); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetService(); + + Assert.NotNull(options); + Assert.Null(options!.TransferFormat); + Assert.Equal(85, options.Quality); + } +} diff --git a/tests/SkiaSharp.Tests.Blazor/SkiaSharp.Tests.Blazor.csproj b/tests/SkiaSharp.Tests.Blazor/SkiaSharp.Tests.Blazor.csproj new file mode 100644 index 00000000000..1f38fb3707f --- /dev/null +++ b/tests/SkiaSharp.Tests.Blazor/SkiaSharp.Tests.Blazor.csproj @@ -0,0 +1,32 @@ + + + + $(TFMCurrent) + SkiaSharp.Tests.Blazor + SkiaSharp.Tests.Blazor + Exe + enable + enable + true + true + true + true + + + + + + + + + + + + + + + + + + +