Updated on
SkiaSharp draws images in .NET with three types. An SKBitmap is the pixel buffer, an SKCanvas is the drawing surface bound to it, and an SKPaint carries the color and style for each drawing call.
Everything in this article is a variation on those three. We allocate a bitmap, attach a canvas, draw a shape with a paint, then hand the bitmap to SKImage to encode it as a PNG on disk.
What Is SkiaSharp in C#?
SkiaSharp is the .NET binding for Skia, Google’s open-source 2D graphics engine and the one that draws Chrome, Android and Flutter. It gives .NET the same drawing primitives those products use.
Three types carry nearly every SkiaSharp program. SKBitmap owns a block of pixels. SKCanvas wraps a bitmap and exposes the drawing calls. SKPaint holds the color, stroke and style that one drawing call should use.
A fourth type shows up when we leave memory. SKImage is an immutable snapshot of pixels, and it is what we encode to PNG, JPEG or WebP bytes.
As a drawing engine, SkiaSharp draws shapes, text and paths onto a surface, and it decodes and encodes the common image formats around that.
The managed package is cross-platform, but the native binary it loads is not. The SkiaSharp package carries Windows and macOS binaries, so a Linux target needs SkiaSharp.NativeAssets.Linux added alongside it.
The Skia project’s site says the library “serves as the graphics engine for Google Chrome and ChromeOS, Android, Flutter, and many other products.”
Let’s start by navigating to our project directory on the terminal to import the NuGet package:
dotnet add package SkiaSharp
How Do We Create a Blank Image With SKBitmap?
Next, let’s create an ImageService static class, with using SkiaSharp; at the top of its file, where we will define some helper methods, beginning with creating a blank image:
public static SKBitmap CreateBlankImage(int width, int height)
{
if (width <= 0 || height <= 0)
{
throw new ArgumentException("Image width and height must be greater than zero.");
}
var bitmap = new SKBitmap(width, height);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.White);
return bitmap;
}
Here we see the definition of our CreateBlankImage() static method, which takes width and height as parameters and returns a bitmap of type SKBitmap, which represents an image in SkiaSharp.
First, we check that both dimensions are greater than zero and throw an ArgumentException if they are not. Without that check, a negative size reaches SKBitmap, which reports it with a bare System.Exception and the message Unable to allocate pixels for the bitmap.
new SKBitmap(width, height) allocates the pixel buffer and zeroes it. A fresh bitmap is not garbage: every pixel reads back as SKColor.Empty, which is fully transparent black.
That changes what the canvas is for. We attach an SKCanvas to the bitmap and call Clear(SKColors.White) only to give the image a white background.
The distinction shows up the moment we save. A PNG keeps the transparency, so a bitmap we never cleared writes out as a transparent image rather than a white one. A JPEG has no alpha channel at all and flattens it.
SKCanvas is disposable and so is SKBitmap. The canvas is a handle onto the bitmap’s pixels, so disposing it does not touch the pixels themselves, and the bitmap stays usable afterwards.
The constructor throws when the dimensions cannot be allocated, including when either one is negative.
Skia documents the zeroing. The buffer comes from SkMallocPixelRef::MakeAllocate(), and the SkMallocPixelRef header says that all pixel bytes are zeroed.
Note that SKCanvas objects are disposable, thus the addition of a using declaration to ensure proper cleanup.
Next, we clear the entire canvas using the SKCanvas.Clear(SKColor) method. The Clear() method sets all the pixels within the canvas to a single color, which in our case is SKColors.White. If we call the parameterless overload of Clear() all pixels will be set to SKColor.Empty (#00000000).
How Do We Draw a Shape on an Image With SKCanvas?
Now, let’s create a method to draw a square at the center of our image:
public static void DrawSquareOnImage(SKBitmap bitmap, int squareSize, int startX, int startY)
{
if (squareSize <= 0 || startX < 0 || startY < 0)
{
throw new ArgumentException("Square size must be greater than zero and coordinates cannot be negative.");
}
using var canvas = new SKCanvas(bitmap);
using var paint = new SKPaint();
paint.Color = SKColors.Red;
var square = new SKRect(startX, startY, startX + squareSize, startY + squareSize);
canvas.DrawRect(square, paint);
}
Here we define our DrawSquareOnImage() method, which will draw a square on the provided image. It takes four parameters namely the SKBitmap object we’ll draw on, squareSize, startX and startY. squareSize represents the size of the square we will draw, while startX and startY represent the X and Y coordinates of the square’s starting point.
First, we validate that squareSize is greater than zero and that neither coordinate is negative. A coordinate of zero is allowed, because (0, 0) is the top-left pixel of the image.
Next, we create a new SKCanvas object for drawing on our bitmap. Following that, we instantiate a new SKPaint object and set its Color to SKColors.Red. We use SKPaint to define the style and color information for drawing objects: geometries, text, and bitmaps.
Next, we create an instance of SKRect, a struct that holds the four coordinates for a rectangle, which in our case, is a square. Then finally, we draw the square on the canvas using the DrawRect() method, which takes both an SKRect parameter (square) and an SKPaint (paint) parameter.
How Do We Save an SKBitmap to a File?
Now, let’s create a method to save an image:
public static void SaveImage(SKBitmap bitmap, string outputPath)
{
using var stream = new FileStream(outputPath, FileMode.Create, FileAccess.Write);
using var image = SKImage.FromBitmap(bitmap);
using var encodedImage = image.Encode();
encodedImage.SaveTo(stream);
}
Here we define our SaveImage() static method, which takes two parameters: bitmap and outputPath.
We first instantiate a FileStream object for file creation, setting the FileMode to FileMode.Create to create or overwrite a file if it already exists, and the FileAccess to FileAccess.Write.
Saving is a two-step conversion. SKImage.FromBitmap() takes an immutable snapshot of the pixels, and Encode() turns that snapshot into compressed bytes we can write to any stream.
The parameterless Encode() writes PNG. Nothing in the call says so: PNG is the method’s default, and the bitmap itself carries no format.
The overload that takes a format and a quality is the one to reach for otherwise. Encode(SKEncodedImageFormat.Jpeg, 80) and Encode(SKEncodedImageFormat.Webp, 80) both work on the same snapshot, and the quality argument runs from 0 to 100.
Encode() returns SKData, which owns unmanaged memory and is disposable. SaveTo() writes it to the stream we opened.
Nothing here is tied to the file system. SaveTo() takes any Stream, so the same three lines write an image into a memory stream, ready for an HTTP response, without touching disk.
PNG is lossless, so the quality argument changes nothing there. On this article’s image, Encode(SKEncodedImageFormat.Png, 100) and Encode(SKEncodedImageFormat.Png, 10) both produce the same 1,177 bytes.
The using declarations ensure that the Dispose() method is called on each disposable object after use, to ensure proper resource management.
Putting the Methods Together
Now, let’s bring our previously created methods into our Program class and create a new image:
string outputPath = @"outputImage.png";
int imageWidth = 400;
int imageHeight = 300;
int squareSize = 120;
int startX = (imageWidth - squareSize) / 2;
int startY = (imageHeight - squareSize) / 2;
var bitmap = ImageService.CreateBlankImage(imageWidth, imageHeight);
ImageService.DrawSquareOnImage(bitmap, squareSize, startX, startY);
ImageService.SaveImage(bitmap, outputPath);
Console.WriteLine("Image generated and saved successfully.");
We specify a path to the directory, where our generated image outputImage.png is saved. The path is relative, so the image lands in the working directory: our project folder when we call dotnet run there, or the folder containing our .exe file, i.e. our “net10.0” folder, when we start the .exe from that folder.
We set the imageWidth, imageHeight and squareSize to 400px, 300px and 120px, respectively.
Next, we compute the starting point for our square with respect to the x-axis by providing the imageWidth and squareSize. Similarly, we provide the imageHeight and squareSize to compute our square’s y-axis starting point.
Afterwards, we invoke the CreateBlankImage() method from our ImageService class, providing the imageWidth and imageHeight, to create our bitmap.
With our image created, we then call our DrawSquareOnImage() method, providing our bitmap and the values we computed earlier: squareSize, startX, startY. With this, the square is drawn on our bitmap.
Finally, we save our modified bitmap to our earlier defined outputPath, with a confirmation message printed on the console.
Let’s test it out:
Image generated and saved successfully.
As expected, we get a success message. Now let’s check our image:

What Do Width, RowBytes and SKColorType Mean in SkiaSharp?
An SKBitmap is a small header plus one flat block of bytes. The header says how to read the block, and each member below is one part of that description.
Width and Height are the pixel dimensions we passed to the constructor. ColorType says how one pixel is laid out in bytes, and BytesPerPixel follows from it.
RowBytes is the stride: the number of bytes from the start of one row to the start of the next. For a bitmap from new SKBitmap(width, height), the stride is simply BytesPerPixel multiplied by Width.
GetPixels() returns an IntPtr to the first byte of that block. This is the member that matters for interop, because an API that wants raw pixels usually wants three things: the pointer, the stride, and the format.
That is why the snippets people copy pass 4 * width as the stride. Four is BytesPerPixel for a 32-bit layout.
Here is what each of those members reports for the 400 by 300 bitmap this article creates:
| Member | What it is | Value for new SKBitmap(400, 300) |
|---|---|---|
Width, Height | The image size in pixels, the two constructor arguments | 400, 300 |
ColorType | The byte layout of a single pixel | SKColorType.Bgra8888 |
AlphaType | How the alpha channel relates to the color channels | SKAlphaType.Premul |
BytesPerPixel | Bytes one pixel occupies, which follows from ColorType | 4 |
RowBytes | Bytes from the start of one row to the start of the next, the stride | 1600, which is 4 * 400 |
GetPixels() | An IntPtr to the first byte of the buffer | Handed to APIs that take a raw pointer |
Pixels | The whole buffer copied out as an SKColor[] | 120,000 entries |
On Windows and on Linux, SKImageInfo.PlatformColorType reports Bgra8888, which is why new SKBitmap(width, height) picks that layout without being asked for one.
Conclusion
In this article, we have looked at generating and manipulating images in C# using the SkiaSharp library by manipulating some images through examples.
If you are choosing between libraries, compare this with generating images with ImageSharp and generating images with ImageMagick, which follow the same steps of creating a blank image, drawing a shape and saving the file.
Tested with .NET 10.0.10 and SkiaSharp 4.153.1.
