Vector graphics rendering in Unreal Engine: Part 3

Rendering SVG images in UMG with Skia.

This is the Big One. SVG is an immensely powerful image format. It yields very small file sizes losslessly, with infinite scalability. Virtually every vector-drawing application has some form of import/export support for SVG, making it extremely portable. It can be trivially edited or even authored by hand in a text editor.

The absence of SVG support in Unreal Engine has been a thorn in my side since I first started using the engine some 5 years ago. Today, we’re going to rectify that.

We’ll start with a brief exploration of the spec, mapping some of the features we aim to support to the Skia API. We’ll create a UMG widget with a clumsy-but-workable API for defining an SVG by hand in the Details panel. Finally, in the next entry, we’ll create a new Unreal asset type, so that we can import SVG files into the Unreal Editor and load them from disk for rendering on-demand.

Investigating the spec (and managing expectations)

As noted by MDN , “SVG is a huge specification,” and this is but a humble blog post. We will not be exhaustively supporting every nook and cranny of the SVG feature-set, because that would be a gargantuan task. Instead, we’ll isolate a small subset of the most common and useful features for UI design. The goal is to allow for designing relatively simple assets that greatly benefit from rendering in a resolution-agnostic way — icons are a prime example — in a design tool like Inkscape, Figma, or Adobe Illustrator, and bringing them into Unreal Engine for use in UMG widget blueprints.

That said, since we’re building all of this functionality from the ground up, my hope is that by the end of this post you’ll be armed with sufficient knowledge to fill in any gaps that may be required to support your particular use case.

Most of the core SVG shape elements map trivially to Skia types. For example, here’s the rect element:

<svg viewBox="0 0 256 256"
	xmlns="http://www.w3.org/2000/svg"
	style="background: #00AAFF"
>
	<rect x="64" y="64"
		width="128"
		height="128"
		fill="#FFFF00"
	/>
</svg>

And here’s the equivalent Skia code :

void draw(SkCanvas* canvas) {
	SkPaint paint;
	paint.setAntiAlias(true);
	
	auto rect = SkRect::MakeXYWH(64, 64, 128, 128);
	
	canvas->clear(0xFF'00AAFF);
	
	paint.setStyle(SkPaint::kFill_Style);
	paint.setColor(0xFF'FFFF00);
	
	canvas->drawRect(rect, paint);
}

Here’s a circle:

<svg viewBox="0 0 256 256"
	xmlns="http://www.w3.org/2000/svg"
	style="background: #00AAFF"
>
	<circle cx="128" cy="128" r="72"
		fill="#FFFF00"
	/>
</svg>

And here it is in Skia :

void draw(SkCanvas* canvas) {
	SkPaint paint;
	paint.setAntiAlias(true);
	
	canvas->clear(0xFF'00AAFF);
	
	paint.setStyle(SkPaint::kFill_Style);
	paint.setColor(0xFF'FFFF00);
	
	canvas->drawCircle(128, 128, 72, paint);
}

One somewhat interesting example is the SVG path. Path commands are specified in SVG using a terse and pretty obtuse syntax that looks something like the following (this particular example is Material Design’s “Favorite” icon ):

<svg viewBox="0 -960 960 960"
	xmlns="http://www.w3.org/2000/svg"
	style="background: #00AAFF"
>
	<path
		d="m480-121-41-37q-105.768-97.121-174.884-167.561Q195-396 154-451.5T96.5-552Q80-597 80-643q0-90.155 60.5-150.577Q201-854 290-854q57 0 105.5 27t84.5 78q42-54 89-79.5T670-854q89 0 149.5 60.423Q880-733.155 880-643q0 46-16.5 91T806-451.5Q765-396 695.884-325.561 626.768-255.121 521-158l-41 37Zm0-79q101.236-92.995 166.618-159.498Q712-426 750.5-476t54-89.135q15.5-39.136 15.5-77.72Q820-709 778-751.5T670.225-794q-51.524 0-95.375 31.5Q531-731 504-674h-49q-26-56-69.85-88-43.851-32-95.375-32Q224-794 182-751.5t-42 108.816Q140-604 155.5-564.5t54 90Q248-424 314-358t166 158Zm0-297Z"
		fill="#FFFF00"
	/>
</svg>

Mercifully, Skia includes a utility to parse these path commands so we don’t have to worry about doing it ourselves:

void draw(SkCanvas* canvas) {
	SkPaint paint;
	paint.setAntiAlias(true);
	
	canvas->clear(0xFF'00AAFF);
	
	paint.setStyle(SkPaint::kFill_Style);
	paint.setColor(0xFF'FFFF00);
	
	SkPath path;
	SkParsePath::FromSVGString(
		"m480-121-41-37q-105.768-97.121-174.884-167.561Q195-396 "
			"154-451.5T96.5-552Q80-597 80-643q0-90.155 60.5-150.577Q201-854 "
			"290-854q57 0 105.5 27t84.5 78q42-54 89-79.5T670-854q89 0 149.5 "
			"60.423Q880-733.155 880-643q0 46-16.5 91T806-451.5Q765-396 "
			"695.884-325.561 626.768-255.121 521-158l-41 37Zm0-79q101.236-92.995 "
			"166.618-159.498Q712-426 750.5-476t54-89.135q15.5-39.136 "
			"15.5-77.72Q820-709 778-751.5T670.225-794q-51.524 0-95.375 "
			"31.5Q531-731 504-674h-49q-26-56-69.85-88-43.851-32-95.375-32Q224-794 "
			"182-751.5t-42 108.816Q140-604 155.5-564.5t54 90Q248-424 314-358t166 "
			"158Zm0-297Z",
		&path);
	
	// Compensate for the odd "0 -960 960 960" viewBox
	SkScalar scaleFactor = 256.f / 960.f;
	SkMatrix transform
		= SkMatrix::Scale(scaleFactor, scaleFactor)
		* SkMatrix::Translate(0, 960.f);
	
	path.transform(transform);
	
	canvas->drawPath(path, paint);
}

And here’s the result .

Bringing SVG rendering into Unreal

We have two goals here:

First, at least temporarily, we want a way to build an SVG from base elements directly in the Unreal Editor. This will not be very practical for most use cases, because SVGs are rarely authored by hand like this (even though it’s technically possible). But it does give us a useful intermediate stage to work with, allowing us to test our code in real-time by manipulating properties in the UMG designer and hot-reloading when we need to make C++ changes.

Second, we want the ability to drag-and-drop an SVG file into the Unreal Editor, where it will be serialized and stored as a static *.uasset that can be loaded from disk and rendered on-demand. This will require parsing SVG’s textual representation, which is the main reason we’re saving it for later.

These two formats will require slightly different type representations — at least, if we want to do things optimally.

Primer on tagged unions and data locality

The most cache-friendly way to store a list of heterogeneous types in C or C++ is by using a tagged union. If you’re not familiar, a tagged union is basically a type that holds a union of types, which represent the actual data; and an enum (the tag), which indicates which “variant” of the union is actually in use at any given time or for any given instance. It’s a little bit hard to clearly describe, so here’s a simple example:

#include <cstdint>
#include <cstring>
#include <iostream>


enum class FooKind : uint8_t {
	None,
	Boolean,
	Int64,
	Float32,
};

struct Foo {
	FooKind Kind;
	union {
		bool Value_Bool;
		int64_t Value_Int64;
		float Value_Float32;
	};
	
	Foo() : Kind(FooKind::None)
	{
		// Zero out the union memory
		std::memset(&Value_Bool, 0, 8);
	}
	
	static Foo Boolean(bool value)
	{
		Foo result;
		result.Kind = FooKind::Boolean;
		result.Value_Bool = value;
		
		return result;
	}
	
	static Foo Int64(int64_t value)
	{
		Foo result;
		result.Kind = FooKind::Int64;
		result.Value_Int64 = value;
		
		return result;
	}
	
	static Foo Float32(float value)
	{
		Foo result;
		result.Kind = FooKind::Float32;
		result.Value_Float32 = value;
		
		return result;
	}
};


int main()
{
	Foo values[3] {
		Foo::Boolean(true),
		Foo::Int64(42),
		Foo::Float32(69.f),
	};
	
	for (int i = 0; i < 3; ++i) {
		switch (values[i].Kind) {
			case FooKind::Boolean:
				std::cout << values[i].Value_Bool << std::endl;
				break;
			
			case FooKind::Int64:
				std::cout << values[i].Value_Int64 << std::endl;
				break;
			
			case FooKind::Float32:
				std::cout << values[i].Value_Float32 << std::endl;
				break;
			
			default:
				std::cout << "<empty>" << std::endl;
				break;
		}
	}
}

The example above would print something like this:

true
42
69.0

The reason an array of tagged unions is relatively cache-efficient is that it allows us to store an arbitrary sequence of heterogeneous types in contiguous memory. A union can only store one of its fields at a time — in our example, a bool, an int64_t, or a float — and thanks to that unusual property, it only needs to occupy as much space as its largest field. In other words, the size of our Foo union is equal to max(sizeof(bool), sizeof(int64_t), sizeof(float)) instead of sizeof(bool) + sizeof(int64_t) + sizeof(float) (plus whatever padding would be required to store them in a given order).

If we wanted to do something like this without a union, we would need to use polymorphism and downcasting instead — something like this:

#include <cstdint>
#include <iostream>


enum class FooKind : uint8_t {
	None,
	Boolean,
	Int64,
	Float32,
};

struct FooBase {
	FooBase(FooKind kind) : Kind(kind) {}
	virtual ~FooBase() = default;
	
	FooKind Kind;
};

struct Foo_Bool : public FooBase {
	explicit Foo_Bool(bool value)
		: FooBase(FooKind::Boolean)
		, Value(value)
	{}
	
	bool Value;
};

struct Foo_Int64 : public FooBase {
	explicit Foo_Int64(int64_t value)
		: FooBase(FooKind::Int64)
		, Value(value)
	{}
	
	int64_t Value;
};

struct Foo_Float32 : public FooBase {
	explicit Foo_Float32(float value)
		: FooBase(FooKind::Float32)
		, Value(value)
	{}
	
	float Value;
};


int main()
{
	FooBase* values[3] {
		new Foo_Bool(true),
		new Foo_Int64(42),
		new Foo_Float32(69.f),
	};
	
	for (int i = 0; i < 3; ++i) {
		switch (values[i]->Kind) {
			case FooKind::Boolean:
				std::cout << dynamic_cast<Foo_Bool*>(values[i])->Value << std::endl;
				break;
			
			case FooKind::Int64:
				std::cout << dynamic_cast<Foo_Int64*>(values[i])->Value << std::endl;
				break;
			
			case FooKind::Float32:
				std::cout << dynamic_cast<Foo_Float32*>(values[i])->Value << std::endl;
				break;
			
			default:
				std::cout << "<empty>" << std::endl;
				break;
		}
	}
}

Needing to store the data as an array of pointers adds an extra layer of indirection: every iteration, we have to jump to the pointer’s location in the array’s memory, and then follow that pointer to the location where the actual data is stored. This means potentially lots of memory fragmentation and CPU cache misses, which is several orders of magnitude slower than an equivalent loop over contiguously-stored elements.

What does this have to do with Unreal?

Unreal Engine has a handy TUnion type, which would be ideal for storing C++ representations of the various types of SVG elements we’ll want to draw — except that TUnion, like most other templated types, cannot be used with the UProperty system due to limitations of the Unreal Header Tool. So to get started, we’ll need to use a polymorphic base UObject-deriving class to allow our SVGs to be constructed in the UMG designer. Later, when we implement SVG asset importing and loading, we’ll investigate using TUnions so we can iterate more efficiently over our SVG elements. To stay relatively agile, we’ll use the same UStructs as wrapped values for both the polymorphic UObject implementation and the tagged union implementation.

Sketching out the SVG element types

For the sake of simplicity, we’ll start with the list of “basic shapes” from MDN’s guide :

Public/SVG/Elements.h
#pragma once

#include "CoreMinimal.h"

#include "Elements.generated.h"


USTRUCT(BlueprintType)
struct API FSVGRect
{
	GENERATED_BODY()
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float X = 0;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float Y = 0;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float Width = 0;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float Height = 0;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	FVector2f Radius = FVector2f::ZeroVector;
};


USTRUCT(BlueprintType)
struct API FSVGCircle
{
	GENERATED_BODY()
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float Radius = 0;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	FVector2f Center = FVector2f::ZeroVector;
};


USTRUCT(BlueprintType)
struct API FSVGEllipse
{
	GENERATED_BODY()
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	FVector2f Radius = FVector2f::ZeroVector;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	FVector2f Center = FVector2f::ZeroVector;
};


USTRUCT(BlueprintType)
struct API FSVGLine
{
	GENERATED_BODY()
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	FVector2f A = FVector2f::ZeroVector;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	FVector2f B = FVector2f::ZeroVector;
};


USTRUCT(BlueprintType)
struct API FSVGPolyLine
{
	GENERATED_BODY()
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	TArray<FVector2f> Points;
};


USTRUCT(BlueprintType)
struct API FSVGPolygon
{
	GENERATED_BODY()
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	TArray<FVector2f> Points;
};


USTRUCT(BlueprintType)
struct API FSVGPath
{
	GENERATED_BODY()
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	FString Commands = "";
};

Then an enum to use as a discriminant:

Public/SVG/Elements.h
// ...

UENUM(BlueprintType)
enum class ESVGElement : uint8
{
	Rect,
	Circle,
	Ellipse,
	Line,
	PolyLine,
	Polygon,
	Path,
};

// ...

Almost all of these elements also support a common set of “presentation attributes,” which more-or-less correspond to SkPaint properties. We’ll create another container for (a subset of) those — we can always add more later.

Public/SVG/Elements.h
// ...

UENUM(BlueprintType)
enum class ECanvasElementFillRule : uint8
{
	NonZero,
	EvenOdd,
};


USTRUCT(BlueprintType)
struct API FCanvasElementStyle
{
	GENERATED_BODY()
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	FColor FillColor = FColor::Transparent;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	ECanvasElementFillRule FillRule = ECanvasElementFillRule::NonZero;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	FColor StrokeColor = FColor::Transparent;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float StrokeWidth = 0;
};

// ...

Polymorphic UClass wrappers

Our base UClass will have the Abstract specifier, because it shouldn’t be a valid option for an SVG element type, and EditInlineNew, indicating that we should be able to create new instances directly from the Details panel instead of needing to select an existing asset. (These specifiers might seem contradictory, and they sort of are — EditInlineNew will actually only apply to the classes that derive from this one, because that specifier is inherited, while Abstract is not.)

We give it the Type enum tag, which gets a UProperty annotation for serialization, but no specifiers because it shouldn’t be user-editable. This field isn’t technically necessary, since we could query the concrete type through reflection (e.g., if (element->IsA<UChildClass>())) — but checking a simple enum field will be cheaper than going through the reflection system, and arguably a little more ergonomic since we can use a switch statement.

The base class also hosts the style properties, since we want them to be shared by all SVG elements.

Public/SVG/ElementUClasses.h
#pragma once

#include "CoreMinimal.h"
#include "Elements.h"

#include "ElementUClasses.generated.h"


UCLASS(Abstract, EditInlineNew)
class API USVGElementBase : public UObject
{
	GENERATED_BODY()
	
public:
	UPROPERTY()
	ESVGElement Type = ESVGElement::None;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	FCanvasElementStyle Style;
};

Then we can declare the child types. There will be one of these for each of the ESVGElement enumerators and corresponding structs we declared earlier. All of them follow the exact same pattern, so I’ll only spell out a couple of examples here:

Public/SVG/ElementUClasses.h
// ...

UCLASS(DisplayName="Rect")
class API USVGRectElement : public USVGElementBase
{
	GENERATED_BODY()
	
public:
	USVGRectElement(const FObjectInitializer& init)
		: Super(init)
	{
		Type = ESVGElement::Rect;
	}
	
	UPROPERTY(EditAnywhere, Category="Appearance", meta=(ShowOnlyInnerProperties))
	FSVGRect Props;
};


UCLASS(DisplayName="Circle")
class API USVGCircleElement : public USVGElementBase
{
	GENERATED_BODY()
	
public:
	USVGCircleElement(const FObjectInitializer& init)
		: Super(init)
	{
		Type = ESVGElement::Circle;
	}
	
	UPROPERTY(EditAnywhere, Category="Appearance", meta=(ShowOnlyInnerProperties))
	FSVGCircle Props;
};

// ...

The ShowOnlyInnerProperties specifier doesn’t seem to be working as intended as of writing, but I’ll leave it alone in case it gets fixed in a patch at some point.

Implementing the UMG SVG-Builder widget

Finally, we can implement the UMG widget. First, as always, the boilerplate:

Public/SVG/CanvasSVGBuilder.h
#pragma once

#include "CoreMinimal.h"
#include "CanvasWidget.h"

#include "CanvasSVGBuilder.generated.h"

class SkCanvas;


UCLASS()
class API UCanvasSVGBuilder : public UCanvasWidget
{
	GENERATED_BODY()
	
	using Self = UCanvasSVGBuilder;
	
protected:
	void OnWidgetRebuilt() override;
	void OnDraw(SkCanvas& canvas, FInt32Vector2 size, FVector2f scale) override;
};
Private/SVG/CanvasSVGBuilder.cpp
#include "CanvasSVGBuilder.h"

#include "SCanvasWidget.h"
#include "include/core/SkCanvas.h"


void UCanvasSVGBuilder::OnWidgetRebuilt()
{
	Super::OnWidgetRebuilt();
	
	SlateWidget->OnDraw.BindUObject(this, &Self::OnDraw);
}

void UCanvasSVGBuilder::OnDraw(SkCanvas& canvas, FInt32Vector2 size, FVector2f scale)
{
	// TODO
}

We’ll go ahead and add support for the viewBox attribute, but for now we’ll only actually do anything useful with the Width and Height properties.

Public/SVG/CanvasSVGBuilder.h
// ...

USTRUCT(BlueprintType)
struct API FSVGViewBox
{
	GENERATED_BODY()
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float X = 0;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float Y = 0;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float Width = 0;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float Height = 0;
	
	FORCEINLINE static FSVGViewBox MakeWH(float width, float height)
	{
		return { 0, 0, width, height };
	}
	
	FORCEINLINE bool operator==(const FSVGViewBox& other) const
	{
		return X == other.X
			&& Y == other.Y
			&& Width == other.Width
			&& Height == other.Height;
	}
	
	FORCEINLINE bool operator!=(const FSVGViewBox& other) const
	{
		return !(operator==(other));
	}
	
	FORCEINLINE bool IsNearlyEqual(const FSVGViewBox& other) const
	{
		return FMath::IsNearlyEqual(X, other.X)
			&& FMath::IsNearlyEqual(Y, other.Y)
			&& FMath::IsNearlyEqual(Width, other.Width)
			&& FMath::IsNearlyEqual(Height, other.Height);
	}
};


UCLASS()
class API UCanvasSVGBuilder : public UCanvasWidget
{
	// ...
	
private:
	UPROPERTY(EditAnywhere, Category="Appearance",
		BlueprintGetter="ViewBox",
		BlueprintSetter="SetViewBox")
	FSVGViewBox _ViewBox;
	
public:
	UFUNCTION(BlueprintGetter) const FSVGViewBox& ViewBox() const { return _ViewBox; }
	UFUNCTION(BlueprintSetter) void SetViewBox(const FSVGViewBox& in_viewBox);
	
	// ...
};
Private/SVG/CanvasSVGBuilder.cpp
// ...

void UCanvasSVGBuilder::SetViewBox(const FSVGViewBox& in_viewBox)
{
	if (_ViewBox.IsNearlyEqual(in_viewBox))
		return;
	
	_ViewBox = in_viewBox;
	
	if (SlateWidget.IsValid())
		SlateWidget->RequestRedraw();
}

// ...

Add the array for our elements. For now, we’ll work under the assumption that the array won’t be modified at runtime. It would be pretty difficult to detect if individual elements were mutated, added or removed without some heavy polling, and it would make for expensive updates to re-allocate a whole new array instance just to make a setter work with this field. We could hypothetically expose something like a RequestRedraw method on the UMG widget to at least make array updates work manually if necessary — but in all likelihood, YAGNI .

Public/SVG/CanvasSVGBuilder.h
UCLASS()
class API UCanvasSVGBuilder : public UCanvasWidget
{
	// ...
public:
	UPROPERTY(EditAnywhere, Instanced, Category="Appearance")
	TArray<USVGElementBase*> Elements;
	// ...
};

OnDraw dispatcher

With that, we can start fleshing out our OnDraw implementation. This will basically be a dispatcher: After some setup, we’ll iterate over each element, identify its type, and delegate the actual drawing operation to a specific function for each.

All of those individual functions are going to take the same set of arguments, so instead of declaring all of our draw functions with a ton of identical parameters each, we’ll declare an internal struct as a container for them. That way we don’t need to update every single function signature every time we realize we need to tweak the arguments list.

Alternatively, we could have added a pure-virtual Draw method to some base struct that all of our element structs would override, which is the more traditional way to do dynamic dispatch. The virtual function calls would incur some likely negligible runtime overhead, but mostly this is a stylistic choice and pretty inconsequential either way.

For the arguments struct, we’ll obviously need the SkCanvas. We’ll store it as a reference, which is a little unorthodox — you usually see poiners in member variable declarations, not references — but there’s no reason it should ever actually be null. Using a reference type also encourages us to keep the struct lifetime tightly scoped to the OnDraw function body, since it prevents us from instantiating the struct outside of that function. This is good, because we don’t “own” that SkCanvas. For all we know, it may only be valid for the duration of our function call, so we shouldn’t be hanging on to any references to it when that function is not actively executing.

We’ll add a shared SkPaint instance to the args as well, to avoid needing to create a new one for each element. Similarly, several of the specific draw functions will need to use an SkPath, which dynamically allocates, so we’ll create one of those for our struct and rewind it after each iteration to reset its state while potentially reusing its allocated storage.

Each draw function will also need the scale, and the style for that element. The style will actually be unique per-element, but we can just update the field value in the dispatcher before calling the target function.

Here are the new header declarations:

Public/SVG/CanvasSVGBuilder.h
UCLASS()
class API UCanvasSVGBuilder : public UCanvasWidget
{
	// ...
private:
	struct FDrawArgs
	{
		FDrawArgs(SkCanvas& canvas, FVector2f scale = FVector2f::UnitVector)
			: Canvas(canvas)
			, Scale(scale)
		{}
		
		SkCanvas& Canvas;
		SkPaint Paint;
		SkPath Path;
		FVector2f Scale;
		FCanvasElementStyle Style;
	};
	
	static void DrawRect(FDrawArgs& args, const FSVGRect& rect);
	static void DrawCircle(FDrawArgs& args, const FSVGCircle& circle);
	static void DrawEllipse(FDrawArgs& args, const FSVGEllipse& ellipse);
	static void DrawLine(FDrawArgs& args, const FSVGLine& line);
	static void DrawPolyLine(FDrawArgs& args, const FSVGPolyLine& polyLine);
	static void DrawPolygon(FDrawArgs& args, const FSVGPolygon& polygon);
	static void DrawPath(FDrawArgs& args, const FSVGPath& path);
};

Note that all of those methods are static, so we could (and likely will) decouple them from this particular class at some point. For now, they’re fine where they are.

Here’s our dispatcher:

Private/SVG/CanvasSVGBuilder.cpp
// ...

void UCanvasSVGBuilder::OnDraw(SkCanvas& canvas, FInt32Vector2 size, FVector2f)
{
	canvas.clear(SK_ColorTRANSPARENT);
	
	float width = size.X;
	float height = size.Y;
	
	FDrawArgs args (canvas, FVector2f {
		width / _ViewBox.Width,
		height / _ViewBox.Height,
	});
	
	args.Paint.setAntiAlias(true);
	
	for (USVGElementBase* element : Elements)
	{
		if (!element->IsValidLowLevel())
			continue;
		
		const FCanvasElementStyle& style = element->Style;
		
		if (style.FillColor.A == 0
			&& (style.StrokeColor.A == 0 || FMath::IsNearlyZero(style.StrokeWidth)))
		{
			continue;
		}
		
		args.Style = style;
		
		switch (element->Type)
		{
			default:
			case ESVGElement::None:
				break;
			
			case ESVGElement::Rect:
				DrawRect(args, Cast<USVGRectElement>(element)->Props);
				break;
			
			case ESVGElement::Circle:
				DrawCircle(args, Cast<USVGCircleElement>(element)->Props);
				break;
			
			case ESVGElement::Ellipse:
				DrawEllipse(args, Cast<USVGEllipseElement>(element)->Props);
				break;
			
			case ESVGElement::Line:
				DrawLine(args, Cast<USVGLineElement>(element)->Props);
				break;
			
			case ESVGElement::PolyLine:
				DrawPolyLine(args, Cast<USVGPolyLineElement>(element)->Props);
				break;
			
			case ESVGElement::Polygon:
				DrawPolygon(args, Cast<USVGPolygonElement>(element)->Props);
				break;
			
			case ESVGElement::Path:
				DrawPath(args, Cast<USVGPathElement>(element)->Props);
				break;
		}
		
		args.Path.rewind();
	}
}

Pretty straightforward, but do note that this time, we’re deriving the scale from the difference between our ViewBox and the render target size instead of using the argument from the Slate widget. I haven’t exhaustively tested that logic, so it’s possible that’s not exactly what we want — it may be a better idea to set the Slate widget’s DesiredSizeOverride from our ViewBox properties and continue treating the scale argument as canonical. We can always revisit it if we run into scaling issues later.

Per-element Draw functions

Now we can dig in to the individual draw calls.

Rect

Private/SVG/CanvasSVGBuilder.cpp
// ...

void UCanvasSVGBuilder::DrawRect(FDrawArgs& args, const FSVGRect& rect)
{
	auto& [canvas, paint, path, scale, style] = args;
	
	auto skRect = SkRect::MakeXYWH(
		rect.X * scale.X,
		rect.Y * scale.Y,
		rect.Width * scale.X,
		rect.Height * scale.Y);
	
	// ...
}

That weird auto& declaration at the top, if you’re not familiar, is a C++17 feature called a “structured binding” (what most languages would call “destructuring”). This is an idiom I’ve brought with me from JavaScript, and it’s arguably a bit fraught to use like this: the types and values of the local declarations depend on the order — not the names — of the corresponding struct fields, which means that changing the layout of FDrawArgs could lead to some slightly confusing error messages in our draw functions. Nevertheless, I don’t want to waste time (or screen real-estate) typing args.<member> dozens of times over the course of these implementations, so I’ll leave it in place for now — this code will ultimately be pretty short-lived anyway.

We don’t really want to tell Skia to waste time trying to paint transparent fill colors or zero-width strokes, so we should check those properties before making the actual draw calls.

Private/SVG/CanvasSVGBuilder.cpp
// ...

void UCanvasSVGBuilder::DrawRect(FDrawArgs& args, const FSVGRect& rect)
{
	// ...
	
	if (style.FillColor.A)
	{
		paint.setStyle(SkPaint::kFill_Style);
		paint.setColor(style.FillColor.ToPackedARGB());
		
		canvas.drawRect(skRect, paint);
	}
	
	if (style.StrokeColor.A && !FMath::IsNearlyZero(style.StrokeWidth))
	{
		paint.setStyle(SkPaint::kStroke_Style);
		paint.setColor(style.StrokeColor.ToPackedARGB());
		paint.setStrokeWidth(style.StrokeWidth * scale.X); // TODO
		
		canvas.drawRect(skRect, paint);
	}
}

SkPaint::setStrokeWidth takes a single scalar value, but our scale vector could theoretically be non-uniform. I don’t really have a great solution in mind for that yet — we could use the compound path technique like we did for the variable borders in our Canvas Rect implementation, but that’s going to be a lot of code to write, because we’ll have to do it for every single one of these elements, so I’m holding off until I can come up with a way to abstract it cleanly and efficiently. For now, I’m using using an arbitrary component of our scale vector and leaving a TODO comment.

Speaking of abstractions — we’re also going to be repeating this pattern of checking the style properties before painting for every single element, and that is something we can abstract pretty easily, so let’s go ahead and do it:

Private/SVG/CanvasSVGBuilder.cpp
// ...
namespace Skia {

/**
 * @returns true if the paint styles should be applied.
 */
FORCEINLINE bool ConfigureFill(SkPaint& paint, const FCanvasElementStyle& style)
{
	if (!style.FillColor.A)
		return false;
	
	paint.setStyle(SkPaint::kFill_Style);
	paint.setColor(style.FillColor.ToPackedARGB());
	
	return true;
}

/**
 * @returns true if the paint styles should be applied.
 */
FORCEINLINE bool ConfigureStroke(
	SkPaint& paint,
	const FCanvasElementStyle& style,
	FVector2f scale)
{
	if (!style.StrokeColor.A || FMath::IsNearlyZero(style.StrokeWidth))
		return false;
	
	paint.setStyle(SkPaint::kStroke_Style);
	paint.setColor(style.StrokeColor.ToPackedARGB());
	paint.setStrokeWidth(style.StrokeWidth * scale.X); // TODO
	
	return true;
}

} // namespace Skia

// ...

void UCanvasSVGBuilder::DrawRect(FDrawArgs& args, const FSVGRect& rect)
{
	// ...
	
	if (Skia::ConfigureFill(paint, style))
		canvas.drawRect(skRect, paint);
	
	if (Skia::ConfigureStroke(paint, style, scale))
		canvas.drawRect(skRect, paint);
}

We’re almost there now, but SVG actually supports 2D corner radii for the rect element, so we’ll also need to check for that and adjust accordingly. Here’s the final DrawRect implementation in full:

Private/SVG/CanvasSVGBuilder.cpp
// ...

void UCanvasSVGBuilder::DrawRect(FDrawArgs& args, const FSVGRect& rect)
{
	auto& [canvas, paint, path, scale, style] = args;
	
	auto skRect = SkRect::MakeXYWH(
		rect.X * scale.X,
		rect.Y * scale.Y,
		rect.Width * scale.X,
		rect.Height * scale.Y);
	
	if (!FMath::IsNearlyZero(rect.Radius.X)
		&& !FMath::IsNearlyZero(rect.Radius.Y))
	{
		auto rRect = SkRRect::MakeRectXY(
			skRect,
			rect.Radius.X * scale.X,
			rect.Radius.Y * scale.Y);
		
		if (Skia::ConfigureFill(paint, style))
			canvas.drawRRect(rRect, paint);
		
		if (Skia::ConfigureStroke(paint, style, scale))
			canvas.drawRRect(rRect, paint);
	}
	else
	{
		if (Skia::ConfigureFill(paint, style))
			canvas.drawRect(skRect, paint);
		
		if (Skia::ConfigureStroke(paint, style, scale))
			canvas.drawRect(skRect, paint);
	}
}

Circle

For DrawCircle, we run into some trickiness with non-uniform scaling due to the single-scalar radius property. There are several ways to skin this particular cat, but I decided to just trace the unmodified circle as a path and transform it with a scale matrix:

Private/SVG/CanvasSVGBuilder.cpp
// ...
void UCanvasSVGBuilder::DrawCircle(FDrawArgs& args, const FSVGCircle& circle)
{
	auto& [canvas, paint, path, scale, style] = args;
	
	if (FMath::IsNearlyEqual(scale.X, scale.Y))
	{
		float cx = circle.Center.X * scale.X;
		float cy = circle.Center.Y * scale.Y;
		float r = circle.Radius * scale.X;
		
		if (Skia::ConfigureFill(paint, style))
			canvas.drawCircle(cx, cy, r, paint);
		
		if (Skia::ConfigureStroke(paint, style, scale))
			canvas.drawCircle(cx, cy, r, paint);
	}
	else
	{
		float cx = circle.Center.X;
		float cy = circle.Center.Y;
		float r = circle.Radius;
		
		path.addCircle(cx, cy, r);
		path.transform(SkMatrix::Scale(scale.X, scale.Y));
		
		if (Skia::ConfigureFill(paint, style))
			canvas.drawPath(path, paint);
		
		if (Skia::ConfigureStroke(paint, style, scale))
			canvas.drawPath(path, paint);
	}
}

Ellipse, Line

DrawEllipse and DrawLine are refreshingly straightforward, with no real gotchas besides the stroke-width issue that we’re ignoring for now:

Private/SVG/CanvasSVGBuilder.cpp
// ...

void UCanvasSVGBuilder::DrawEllipse(FDrawArgs& args, const FSVGEllipse& ellipse)
{
	auto& [canvas, paint, path, scale, style] = args;
	
	float cx = ellipse.Center.X * scale.X;
	float cy = ellipse.Center.Y * scale.Y;
	
	float rx = ellipse.Radius.X * scale.X;
	float ry = ellipse.Radius.Y * scale.Y;
	
	auto oval = SkRect::MakeXYWH(cx-rx, cy-ry, rx*2, ry*2);
	
	if (Skia::ConfigureFill(paint, style))
		canvas.drawOval(oval, paint);
	
	if (Skia::ConfigureStroke(paint, style, scale))
		canvas.drawOval(oval, paint);
}

void UCanvasSVGBuilder::DrawLine(FDrawArgs& args, const FSVGLine& line)
{
	auto& [canvas, paint, path, scale, style] = args;
	
	if (!Skia::ConfigureStroke(paint, style, scale))
		return;
	
	float x0 = line.A.X * scale.X;
	float y0 = line.A.Y * scale.Y;
	
	float x1 = line.B.X * scale.X;
	float y1 = line.B.Y * scale.Y;
	
	canvas.drawLine(x0, y0, x1, y1, paint);
}

PolyLine, Polygon

DrawPolyLine is pretty simple, except that the API is a little unintuitive. There are a couple of techniques that initially look like they could work — SkPath::addPoly and SkCanvas::drawPoints(kLines_PointMode, ...) — but the latter seems intended for drawing multiple discrete line segments from a set of point pairs. We’ll use the former — the boolean argument at the end indicates whether the last point should be connected back to the first.

We’ll also specify the path’s “fill type” property here (via SVG’s “fill rule” attribute), since it’s possible for the line segments to cross over one another, and governing the behavior of those overlaps is the use case for that attribute.

Private/SVG/CanvasSVGBuilder.cpp
// ...
namespace Skia {

FORCEINLINE SkPathFillType FillType(ECanvasElementFillRule rule)
{
	switch (rule)
	{
		default:
		case ECanvasElementFillRule::NonZero:
			return SkPathFillType::kWinding;
		
		case ECanvasElementFillRule::EvenOdd:
			return SkPathFillType::kEvenOdd;
	}
}

// ...
} // namespace Skia

// ...

void UCanvasSVGBuilder::DrawPolyLine(FDrawArgs& args, const FSVGPolyLine& polyLine)
{
	auto& [canvas, paint, path, scale, style] = args;
	
	TArray<SkPoint> points;
	Algo::Transform(polyLine.Points, points, [scale](FVector2f p) -> SkPoint
	{
		return {
			p.X * scale.X,
			p.Y * scale.Y,
		};
	});
	
	path.addPoly(points.GetData(), points.Num(), false);
	path.setFillType(Skia::FillType(style.FillRule));
	
	if (Skia::ConfigureFill(paint, style))
		canvas.drawPath(path, paint);
	
	if (Skia::ConfigureStroke(paint, style, scale))
		canvas.drawPath(path, paint);
}

DrawPolygon is identical to DrawPolyLine, except the aforementioned boolean argument will be set to true.

Private/SVG/CanvasSVGBuilder.cpp
// ...

void UCanvasSVGBuilder::DrawPolygon(FDrawArgs& args, const FSVGPolygon& polygon)
{
	auto& [canvas, paint, path, scale, style] = args;
	
	TArray<SkPoint> points;
	Algo::Transform(polygon.Points, points, [scale](FVector2f p) -> SkPoint
	{
		return {
			p.X * scale.X,
			p.Y * scale.Y,
		};
	});
	
	path.addPoly(points.GetData(), points.Num(), true);
	path.setFillType(Skia::FillType(style.FillRule));
	
	if (Skia::ConfigureFill(paint, style))
		canvas.drawPath(path, paint);
	
	if (Skia::ConfigureStroke(paint, style, scale))
		canvas.drawPath(path, paint);
}

Path

And finally we have DrawPath, which is straightforward except for the need to convert Unreal’s platform-dependent character encoding to char for Skia.

Private/SVG/CanvasSVGBuilder.cpp
// ...

void UCanvasSVGBuilder::DrawPath(FDrawArgs& args, const FSVGPath& svgPath)
{
	auto& [canvas, paint, path, scale, style] = args;
	
	SkParsePath::FromSVGString(TCHAR_TO_ANSI(*svgPath.Commands), &path);
	
	path.setFillType(Skia::FillType(style.FillRule));
	path.transform(SkMatrix::Scale(scale.X, scale.Y));
	
	if (Skia::ConfigureFill(paint, style))
		canvas.drawPath(path, paint);
	
	if (Skia::ConfigureStroke(paint, style, scale))
		canvas.drawPath(path, paint);
}

Checking our work

Feel free to start up the editor and drop a Canvas SVG Builder into a widget blueprint to give it a test run. Here’s a replica of the SVG I drew up in Figma to demonstrate tracing a manual path for a rounded rectangle in the last entry, but make sure to put all of the element types we defined through their paces.

Screenshot of the Canvas SVG Builder widget rendering a custom SVG image in Unreal Engine's UMG designer

If you go through the process of trying to manually re-author an existing SVG in the Unreal Editor, as I’ve just done, you will probably come to the conclusion that this workflow is definitely not sustainable. We need a way to import and load SVG files so we can author them in an external tool and bring them directly into Unreal. Unfortunately, this post has already gone on longer than I intended, so we’ll save that exercise for the next (and likely final) entry in this series.