WebGPUWindowDemo is the smallest end-to-end sample in the repo that renders ImageSharp.Drawing content directly into a native presentable window using the WebGPU backend.
It exists to show the intended shape of a real-time app:
- create a
WebGPUWindow - let the window own swapchain acquisition and presentation
- draw with the normal
DrawingCanvasAPI - present by ending the acquired frame
The sample opens an 800x600 window, draws a dark background, animates 1000 bouncing ellipses, scrolls a block of pre-shaped text, and updates the window title with frame timing statistics.
This demo is the clearest reference for the window-first WebGPU API surface:
WebGPUWindowowns the OS window, WebGPU surface, adapter, device, queue, and swapchain configuration.WebGPUSurfaceFramerepresents one acquired drawable frame.WebGPUSurfaceFrame.Canvasis the normalDrawingCanvasyou already use elsewhere in ImageSharp.Drawing.- disposing the frame renders pending canvas work, presents the surface texture, and releases the per-frame WebGPU handles.
That means sample code stays focused on drawing and animation instead of explicit texture acquisition, presentation, or interop setup.
dotnet run --project samples/WebGPUWindowDemo -c DebugRequirements:
- .NET 8.0 SDK or later
- a WebGPU-capable desktop backend such as D3D12, Vulkan, or Metal
- adapter support for the storage-capable BGRA format selected by the sample
When the sample starts you should see:
- a native window titled
ImageSharp.Drawing WebGPU Demo - animated semi-transparent balls bouncing around the viewport
- a large scrolling text block in the background
- the title bar updating once per second with current frame time, current FPS, mean FPS, and FPS standard deviation
Everything lives in Program.cs.
Main() creates the window and chooses the presentation mode:
using WebGPUWindow window = new(new WebGPUWindowOptions
{
Title = "ImageSharp.Drawing WebGPU Demo",
Size = new Size(800, 600),
Format = WebGPUTextureFormat.Bgra8Unorm,
PresentMode = WebGPUPresentMode.Fifo,
});Important details:
WebGPUTextureFormat.Bgra8Unormselects the swapchain format. The WebGPU factory creates the matching typed canvas internally.WebGPUPresentMode.Fifogives normal v-synced presentation behavior.- no manual WebGPU bootstrap code is needed in the sample;
WebGPUWindowhandles surface, adapter, device, queue, and swapchain setup internally.
DemoApp owns the sample state:
- the window reference
- a deterministic
Random - the
Ball[]animation state - cached text paths
- FPS accumulation state
InitializeScene() does the expensive one-time work:
- creates an
Arialfont at 24px - builds
TextOptionsusing the current framebuffer width - shapes the scrolling text once with
TextBuilder.GeneratePaths(...) - measures the total text height with
TextMeasurer.MeasureSize(...) - creates 1000 random balls sized and positioned for the current framebuffer
The important pattern here is that text shaping is not done every frame. The sample converts the whole text block into vector paths once, then reuses that geometry as the text scrolls.
DemoApp subscribes to window.Update in its constructor:
this.window.Update += this.OnUpdate;OnUpdate(TimeSpan deltaTime) performs simulation only:
- each ball advances by
velocity * dt - each ball reflects off the framebuffer edges
- the text scroll offset advances at
200pixels per second
Separating animation from rendering keeps the sample structure close to a normal game or interactive tool.
Run() calls:
this.window.Run(this.OnRender);WebGPUWindow.Run(...) acquires one WebGPUSurfaceFrame per render callback and disposes it automatically after your callback returns. In this sample that means you do not call Flush() yourself.
Inside OnRender(...) the sample:
- grabs
DrawingCanvas canvas = frame.Canvas - fills the full frame with a solid background color
- draws the scrolling text block
- fills one ellipse per ball
- updates the window title once per second with timing statistics
The drawing code is intentionally plain DrawingCanvas API usage:
canvas.Fill(Brushes.Solid(...))for the backgroundcanvas.Fill(textBrush, path)for text geometrycanvas.Fill(Brushes.Solid(ball.Color), ellipse)for the balls
That is the point of the sample: the WebGPU path should feel like normal ImageSharp.Drawing usage, not a separate graphics API.
DrawScrollingText(...) shows the most important optimization in the sample.
Instead of rebuilding glyphs every frame, it:
- computes a wrapped vertical scroll offset
- builds a translation matrix for the current frame
- saves a transformed canvas state with
canvas.Save(translatedOptions) - culls any path whose translated bounds are outside the viewport
- fills only the visible paths
- restores the prior canvas state with
canvas.Restore()
The culling is simple but effective: large amounts of off-screen text never get submitted for rasterization.
This sample uses the Run(Action<WebGPUSurfaceFrame>) overload, so frame lifetime is important:
- the window acquires the current surface texture
- the frame wraps that texture in a
DrawingCanvas - your render callback queues draw operations
- frame disposal renders the queued canvas work and presents the surface
- the frame releases the texture and texture view
Two practical consequences:
- you do not need to call
canvas.Flush()in this sample - manual frame loops should dispose each acquired frame exactly once
The sample renders into a real native presentable surface. The final destination is GPU-native, but the pipeline is still hybrid:
- vector scene preparation and coverage generation happen through the normal drawing backend flow
- the WebGPU backend uploads the prepared data to GPU resources
- final composition into the swapchain texture happens on the GPU through WebGPU compute work
So this demo is best understood as "ImageSharp.Drawing rendered into a native WebGPU window target" rather than "every drawing step is implemented as pure GPU vector rasterization."
If you want control over your own loop instead of Run(...), use TryAcquireFrame(...):
if (window.TryAcquireFrame(out WebGPUSurfaceFrame? frame))
{
using (frame)
{
DrawingCanvas canvas = frame.Canvas;
canvas.Fill(Brushes.Solid(Color.Black));
canvas.Fill(Brushes.Solid(Color.CornflowerBlue), new EllipsePolygon(200, 150, 80));
}
}Notes:
- a
falseresult is normal retry behavior, not necessarily an error - this can happen when the surface is outdated, lost, timed out, or the framebuffer is currently zero-sized
- disposing the frame renders queued canvas work, presents the surface, and releases per-frame resources
The sample builds the scrolling text layout once from the startup framebuffer size. That keeps the demo simple and avoids reshaping text during the steady-state render loop.
As a result:
- the animation keeps working after resize because balls update against the current framebuffer size
- the text continues to render
- the text wrapping width is based on the initial framebuffer width, not a reflowed width after resize
That tradeoff is acceptable for a demo because the sample is trying to show rendering flow, cached path reuse, and frame presentation rather than full responsive layout management.
- Program.cs: the entire sample
- WebGPUWindowDemo.csproj: sample project file
- README.md: this document