Vector graphics rendering in Unreal Engine: Part 2

Building a dynamic rectangle widget with independent rounded corners and border widths.

When I first made the argument for the type of graphics API that we’ve now integrated, I used the example of a simple box style with a background color and rounded corners. It seems trivial, but this is the type of thing that’s currently pretty laborious to build with Unreal Engine’s UI tools, requiring either a 9-sliced texture asset or a UI material with a complex signed distance function to pull off the rounded corners.

But the CSS spec allows for quite a bit more flexibility than that simple example demonstrated. We can round corners independenly, for example:

example.css
.independent-corners {
	border-top-left-radius: 64px;
	border-top-right-radius: 16px;
	border-bottom-right-radius: 48px;
	border-bottom-left-radius: 8px;
}

(If you’re wondering about the practicality of that technique, I’m using it on this very blog: Notice how the top-left corners of the code blocks’ main content boxes are the only ones that are squared, and how only the top corners of the “tabs” displaying the filenames are rounded.)

Independent borders can also be applied via CSS:

example.css
.independent-corners-and-borders {
	border-top-left-radius: 64px;
	border-top-right-radius: 16px;
	border-bottom-right-radius: 48px;
	border-bottom-left-radius: 8px;
	border-top-width: 16px;
	border-right-width: 4px;
	border-bottom-width: 16px;
	border-left-width: 4px;
}

That’s a technique I use very often, for example, to highlight the active item in a navigation list:

Building on the work we did in Part 1, we’re going to build a UMG widget that implements these features using Skia.

A brief exploration of Skia’s API

One of Skia’s principal raisons d’être is to serve as the rendering engine behind Google Chrome, but its API is quite a bit lower-level than the CSS specification, so you won’t find a simple SkCanvas method that directly does what we’re aiming for. Since it’s open-source, we could dig through Chromium’s source code to try and find its implementation of these features, but my experience with reading Google source code leads me to suspect that, wihout assistance, we’d run into a maze of abstractions that would take quite a while to trace down to the actual Skia calls we’re interested in.

Instead, we’ll take a look at Skia’s API and forge our own path (pun intended). The simplest place to start is with SkRect. We can draw a simple rectangle, without rounded corners, like this:

void OnDraw(SkCanvas& canvas)
{
	SkPaint paint;
	paint.setColor(Sk_ColorWHITE);
	paint.setStyle(SkPaint::kFill_Style);
	
	// NOTE: This is using C++20's designated initializers syntax for clarity,
	//       but we won't actually be using any C++20 features in Unreal code.
	SkRect rect {
		.fLeft = 0,
		.fTop = 0,
		.fRight = 100,
		.fBottom = 100,
	};
	
	canvas.clear(Sk_ColorBLACK);
	canvas.drawRect(rect, paint);
}

That will draw a 100 × 100 white rectangle on a black background, anchored to the top left corner of the surface. Thinking of a rectangle in terms of each side’s offset from the origin is not overly difficult, but also not the most intuitive thing in the world. Most Skia types, including SkRect, also have static methods to construct them from various sets of parameters, like this one that takes offsets and dimensions, so you don’t need to manually add the top and left offsets to the width and height to derive the correct bottom and right offsets:

void OnDraw(SkCanvas& canvas)
{
	SkPaint paint;
	paint.setColor(Sk_ColorWHITE);
	paint.setStyle(SkPaint::kFill_Style);
	
	auto rect = SkRect::MakeXYWH(5, 5, 100, 100);
	// Equivalent to:
	// SkRect rect {
	// 	.fLeft = 5,
	// 	.fTop = 5,
	// 	.fRight = 105,
	// 	.fBottom = 105,
	// };
	
	canvas.clear(Sk_ColorBLACK);
	canvas.drawRect(rect, paint);
}

But what if we want rounded corners? Skia also has an SkRRect to represent rounded rectangles, but we want independently rounded corners. Instead, we’ll try to come up with a solution using the path-drawing API.

Using the SkPath API

(Important: If you’re reading this because you’re trying to actually achieve the end goal, and not just for entertainment or general education, you may want to skip ahead to the next section.)

SkPath represents an arbitrary vector path, of the sort that you might create with the ubiquitous “pen tool” in an application like Inkscape, Figma, or Adobe Illustrator. In Skia, we define a path by issuing a sequence of commands, which you can think of as describing actions to be performed by a virtual pen.

The main commands we’re interested in are:

  • moveTo(x, y), which raises the pen from the surface and presses its tip down at a new set of coordinates
  • lineTo(x, y), which draws a straight line from the pen’s current location to the new set of coordinates
  • arcTo(oval, startAngle, sweepAngle, forceMoveTo), which draws an inscribed arc with length sweepAngle around the perimeter of oval, beginning at startAngle. forceMoveTo indicates whether the pen should be lifted from the surface at its current position before beginning the arc.

Looking at the example shape at the top of this post, we can easily picture each of the arcs and lines we would need to draw the shape:

For each corner, we need to define the “oval” as an SkRect with a width and height of 2 × that corner’s radius, and offset it to sit flush with the bounding box at that corner. Then we call arcTo on that oval with the correct starting angle, and a sweep angle of 90°. Then we call lineTo with the coordinates of the starting point for the next arc.

But before we bring this knowledge into a UMG widget and go about trying to parameterize it with UPropertys, let’s spin up a quick Skia fiddle and hard-code our values to make sure we know what we’re doing:

void draw(SkCanvas* canvas) {
	SkPath path;
	
	auto cornerTL = SkRect::MakeXYWH(0, 0, 128, 128);
	path.arcTo(cornerTL, -180, 90, true);
	
	path.lineTo(300-16, 0);
	
	auto cornerTR = SkRect::MakeXYWH(300-32, 0, 32, 32);
	path.arcTo(cornerTR, -90, 90, false);
	
	path.lineTo(300, 100-48);
	
	auto cornerBR = SkRect::MakeXYWH(300-96, 100-96, 96, 96);
	path.arcTo(cornerBR, 0, 90, false);
	
	path.lineTo(8, 100);
	
	auto cornerBL = SkRect::MakeXYWH(0, 100-16, 16, 16);
	path.arcTo(cornerBL, 90, 90, false);
	
	path.lineTo(0, 64);
	path.close();
	
	SkPaint paint;
	paint.setAntiAlias(true);
	paint.setColor(SK_ColorWHITE);
	paint.setStyle(SkPaint::kFill_Style);
	
	canvas->clear(SK_ColorTRANSPARENT);
	canvas->drawPath(path, paint);
}

That looks good to me! Next, let’s try substituting those hard-coded magic numbers with variables.

void draw(SkCanvas* canvas) {
	SkScalar width  = 300;
	SkScalar height = 100;
	
	SkScalar radiusTL = 64;
	SkScalar radiusTR = 16;
	SkScalar radiusBR = 48;
	SkScalar radiusBL = 8;
	
	SkPath path;
	
	auto cornerTL = SkRect::MakeXYWH(0, 0, radiusTL*2, radiusTL*2);
	path.arcTo(cornerTL, -180, 90, true);
	
	path.lineTo(width-radiusTR, 0);
	
	auto cornerTR = SkRect::MakeXYWH(width-radiusTR*2, 0, radiusTR*2, radiusTR*2);
	path.arcTo(cornerTR, -90, 90, false);
	
	path.lineTo(width, height-radiusBR);
	
	auto cornerBR = SkRect::MakeXYWH(width-radiusBR*2, height-radiusBR*2, radiusBR*2, radiusBR*2);
	path.arcTo(cornerBR, 0, 90, false);
	
	path.lineTo(radiusBL, height);
	
	auto cornerBL = SkRect::MakeXYWH(0, height-radiusBL*2, radiusBL*2, radiusBL*2);
	path.arcTo(cornerBL, 90, 90, false);
	
	path.lineTo(0, radiusTL);
	path.close();
	
	SkPaint paint;
	paint.setAntiAlias(true);
	paint.setColor(SK_ColorWHITE);
	paint.setStyle(SkPaint::kFill_Style);
	
	canvas->clear(SK_ColorTRANSPARENT);
	canvas->drawPath(path, paint);
}

Still looks good , so we haven’t broken anything yet. It’s still pretty noisy to read, so maybe we could try something slightly more declarative, with a simple loop?

void draw(SkCanvas* canvas) {
	SkScalar width = 300;
	SkScalar height = 100;
	
	SkScalar radii[4] { 64, 16, 48, 8 };
	SkScalar startAngles[4] { -180, -90, 0, 90 };
	
	SkRect corners[4] {
		SkRect::MakeXYWH(0, 0, radii[0]*2, radii[0]*2),
		SkRect::MakeXYWH(width - radii[1]*2, 0, radii[1]*2, radii[1]*2),
		SkRect::MakeXYWH(width - radii[2]*2, height - radii[2]*2, radii[2]*2, radii[2]*2),
		SkRect::MakeXYWH(0, height - radii[3]*2, radii[3]*2, radii[3]*2),
	};
	
	SkPoint startPoints[4] {
		{ corners[0].left(),    corners[0].centerY() },
		{ corners[1].centerX(), corners[1].top() },
		{ corners[2].right(),   corners[2].centerY() },
		{ corners[3].centerX(), corners[3].bottom() },
	};
	
	SkPath path;
	for (int i = 0; i < 4; ++i) {
		path.arcTo(corners[i], startAngles[i], 90, i == 0);
		path.lineTo(startPoints[(i + 1) % 4]);
	}
	path.close();
	
	SkPaint paint;
	paint.setAntiAlias(true);
	paint.setColor(SK_ColorWHITE);
	paint.setStyle(SkPaint::kFill_Style);
	
	canvas->clear(SK_ColorTRANSPARENT);
	canvas->drawPath(path, paint);
}

Also works . It’s debatable whether that’s easier to read than the previous example, but I think I slightly prefer it, so we’ll stick with it.

Variable borders

Drawing the variable-width borders for our rounded rectangle will be a little bit trickier. At a high level, what we want to do is this:

  • Create an outer path, which is the one we’ve been working with so far, covering the outer bounds of the shape.
  • Create an inner path, which is the shape of the negative space in the center. Looking at the second example shape at the top of this post, inner would represent the shape of the purple section.
  • Draw the outer path with the main fill color.
  • Subtract inner from outer, and draw the result of that with the border color.

Why paint the fill color first, and why use the outer shape for that instead of just using the fill color to draw the inner path? Because if the border color is semi-transparent, we want the fill color to show through behind it, as demonstrated here:

.semi-transparent-border {
	width: 300px;
	height: 100px;
	border: 20px solid rgba(0, 0, 255, 0.5); /* blue, 50% opacity */
	background: rgba(255, 0, 0, 1.0);        /* red, 100% opacity */
}

Mind you, there’s nothing forcing us to implement the CSS spec, so we could do it differently if we wanted to. But given that the border is conceptually aligned to the interior of the rectangle (i.e., it doesn’t add additional width/height to the shape, it consumes part of the existing width/height), this setup makes sense to me.

The tricky part is figuring out how to draw this inner path. Here’s the diagram, but it’s a little less obvious how to derive it from our properties:

The inner bounding box is pretty straightforward:

x=blefty=btopw=wouter(bleft+bright)h=houter(btop+bbottom)\begin{aligned} &x = b_\mathrm{left}\\ &y = b_\mathrm{top}\\ &w = w_\mathrm{outer} - (b_\mathrm{left} + b_\mathrm{right})\\ &h = h_\mathrm{outer} - (b_\mathrm{top} + b_\mathrm{bottom}) \end{aligned}

where bsideb_\mathrm{side} is the border-width of the specified side.

The size of an inner corner oval is defined like this:

w=max(2rcorner2bx,0)h=max(2rcorner2by,0)w = \mathrm{max} ( 2r_\mathrm{corner} - 2b_x , 0 ) \\ h = \mathrm{max} ( 2r_\mathrm{corner} - 2b_y , 0 )

where rcornerr_\mathrm{corner} is the radius of the corner in question, and bxb_x / byb_y are the border-widths of the corresponding horizontal / vertical side.

But SkRect actually has a method to make a new rect by insetting an existing one, which would make our math much simpler:

/** Returns SkRect, inset by (dx, dy). */
SkRect SkRect::makeInset(SkScalar dx, SkScalar dy) const;

It doesn’t validate the result, so we would end up with a negative width or height for the top-right and bottom-left corners in our example, since the corresponding border widths are larger than the corner radii. But we could easily make a helper function to constrain the minimum dimensions:

SkRect MakeInnerCorner(
	SkRect const& outerCorner,
	SkScalar bx,
	SkScalar by)
{
	SkRect result = outerCorner.makeInset(bx, by);
	
	if (result.width() < 0) {
		SkScalar x = fminf(result.fLeft, result.fRight);
		result.fLeft = result.fRight = x;
	}
	
	if (result.height() < 0) {
		SkScalar y = fminf(result.fTop, result.fBottom);
		result.fTop = result.fBottom = y;
	}
	
	return result;
}

Let’s put it all together and see how it goes. To start, we’ll just draw the inner path over the outer path with a different color — we’re just validating that the inner path has the correct shape for now.

struct Borders {
	SkScalar left, top, right, bottom;
};

SkRect MakeInnerCorner(
	SkRect const& outerCorner,
	SkScalar bx,
	SkScalar by)
{
	SkRect result = outerCorner.makeInset(bx, by);
	
	if (result.width() < 0) {
		SkScalar x = fmin(result.fLeft, result.fRight);
		result.fLeft = result.fRight = x;
	}
	
	if (result.height() < 0) {
		SkScalar y = fmin(result.fTop, result.fBottom);
		result.fTop = result.fBottom = y;
	}
	
	return result;
}

void draw(SkCanvas* canvas) {
	// Surface size
	SkScalar width = 300;
	SkScalar height = 100;
	
	// Configurable parameters
	SkScalar radii[4] { 64, 16, 48, 8 };
	Borders borders { 4, 16, 4, 16 };
	
	// Start angles for each arc
	SkScalar startAngles[4] { -180, -90, 0, 90 };
	
	// Outer corner ovals
	SkRect corners[4] {
		SkRect::MakeXYWH(0, 0, radii[0]*2, radii[0]*2),
		SkRect::MakeXYWH(width-radii[1]*2, 0, radii[1]*2, radii[1]*2),
		SkRect::MakeXYWH(width-radii[2]*2, height-radii[2]*2, radii[2]*2, radii[2]*2),
		SkRect::MakeXYWH(0, height-radii[3]*2, radii[3]*2, radii[3]*2),
	};
	
	// Starting points for each outer arc
	SkPoint startPoints[4] {
		{ corners[0].left(),    corners[0].centerY() },
		{ corners[1].centerX(), corners[1].top() },
		{ corners[2].right(),   corners[2].centerY() },
		{ corners[3].centerX(), corners[3].bottom() },
	};
	
	// Inner corner ovals
	SkRect innerCorners[4] {
		MakeInnerCorner(corners[0], borders.left, borders.top),
		MakeInnerCorner(corners[1], borders.right, borders.top),
		MakeInnerCorner(corners[2], borders.right, borders.bottom),
		MakeInnerCorner(corners[3], borders.left, borders.bottom),
	};
	
	// Starting points for each inner arc
	SkPoint innerStartPoints[4] {
		{ innerCorners[0].left(),    innerCorners[0].centerY() },
		{ innerCorners[1].centerX(), innerCorners[1].top() },
		{ innerCorners[2].right(),   innerCorners[2].centerY() },
		{ innerCorners[3].centerX(), innerCorners[3].bottom() },
	};
	
	// Prepare our paint and canvas
	SkPaint paint;
	paint.setAntiAlias(true);
	paint.setStyle(SkPaint::kFill_Style);
	
	canvas->clear(SK_ColorTRANSPARENT);
	
	// Draw the outer path
	SkPath outer;
	for (int i = 0; i < 4; ++i) {
		outer.arcTo(corners[i], startAngles[i], 90, i == 0);
		outer.lineTo(startPoints[(i + 1) % 4]);
	}
	outer.close();
	
	paint.setColor(SK_ColorRED);
	canvas->drawPath(outer, paint);
	
	// Draw the inner path
	SkPath inner;
	for (int i = 0; i < 4; ++i) {
		inner.arcTo(innerCorners[i], startAngles[i], 90, i == 0);
		inner.lineTo(innerStartPoints[(i + 1) % 4]);
	}
	inner.close();
	
	paint.setColor(SK_ColorBLUE);
	canvas->drawPath(inner, paint);
}

That looks like the result we were aiming for , so now we can refactor to the method I mentioned earlier, where we first paint the outer path with the fill color, and then draw the border over top of that. But to do that, we need to figure out how to subtract the inner path from the outer path.

Boolean path operations

Traditionally, whether any given path contour is positive or negative is determined by its “winding.” This is similar to how 3D graphics APIs infer the normal direction of a triangle: if the points of a closed path contour are drawn in clockwise order, we have a positive shape (i.e., one whose interior will be filled). If they’re drawn counter-clockwise, then we have a negative shape (i.e., one that cuts a “hole” out of a positive shape).

But we’ve already drawn our inner path clockwise, and it would be kind of a pain to refactor our code to draw it counter-clockwise instead. Fortunately, Skia paths have a “fill type” property, which can be configured with the SkPathFillType enum to change the standard behavior. One of the options is SkPathFillType::kInverseWinding, which is exactly what we want. But we don’t actually have to configure it manually in our case — SkPath has a helper method, reverseAddPath, that lets us append a new path to an existing path as if the new one were inverse-wound.

Here’s the updated code:

// ...

void draw(SkCanvas* canvas) {
	// ...
	SkColor fillColor = 0xFF'46278E;
	SkColor borderColor = 0xFF'0FDEBD;
	// ...
	
	// Trace the outer path
	SkPath outer;
	for (int i = 0; i < 4; ++i) {
		outer.arcTo(corners[i], startAngles[i], 90, i == 0);
		outer.lineTo(startPoints[(i + 1) % 4]);
	}
	outer.close();
	
	// Trace the inner path
	SkPath inner;
	for (int i = 0; i < 4; ++i) {
		inner.arcTo(innerCorners[i], startAngles[i], 90, i == 0);
		inner.lineTo(innerStartPoints[(i + 1) % 4]);
	}
	inner.close();
	
	// Paint the fill color
	paint.setColor(fillColor);
	canvas->drawPath(outer, paint);
	
	// Subtract the inner path from outer
	outer.reverseAddPath(inner);
	
	// Paint the border color
	paint.setColor(borderColor);
	canvas->drawPath(outer, paint);
}

And here’s the result ! Feel free to play around with the opacity of the border color (the first two digits in the SkColor hex) to see the alpha-blending behavior — here it is with the border opacity at 50%.

A much simpler method

At this point it’s only right to admit that I lied to you pretty egregiously at the top of this post. Or to be more precise, I failed to investigate Skia’s API thoroughly enough when I originally implemented this feature in my own project, and only discovered my error during the course of writing this post about it.

Instead of first rewriting my implementation, and then writing this post to reflect that (as if I had actually done it properly the first time around), I thought it would be illuminating to walk through the entire journey I took to get here — first, because it offers a nice exploration of Skia’s path-drawing APIs, but second (and more importantly) because it makes for an important lesson: don’t make premature assumptions, and always RTFM (read the freaking manual).

Remember when I said this?

One of Skia’s principal raisons d’être is to serve as the rendering engine behind Google Chrome, but its API is quite a bit lower-level than the CSS specification, so you won’t find a simple SkCanvas method that directly does what we’re aiming for.

That is not quite correct. Sort of the opposite of correct, actually. SkRRect does exactly what we’re aiming for, with a single SkCanvas draw call and a much simpler API.

From Skia’s API reference :

SkRRect describes a rounded rectangle with a bounds and a pair of radii for each corner.

Emphasis mine (which is the part I missed the first time around). Hilariously, if you expand the full description, it later explicitly spells out the fact that it can be used to “[implement] CSS properties that describe rounded corners.” Sigh.

SkRRect implementation

The most flexible SkRRect API requires us to specify both the x and y radius for each corner, while the less flexible ones allow only a global x and y radius which would be applied to all corners uniformly. We’ll need to use the former, which means we need to convert our array of scalar radii to an array of vector radii. Our UMG widget’s API will allow either one or four scalars (uniform or per-corner), so instead of just writing each value twice, we’ll use a simple <algorithm> import to transform our existing array, and refactor to the Unreal equivalent once we get to the UMG implementation.

#include <algorithm>

struct Borders {
	SkScalar left, top, right, bottom;
};

void draw(SkCanvas* canvas) {
	// Surface size
	SkScalar width = 300;
	SkScalar height = 100;
	
	// Configurable parameters
	SkScalar radii[4] { 64, 16, 48, 8 };
	Borders borders { 4, 16, 4, 16 };
	SkColor fillColor = 0xFF'46278E;
	SkColor borderColor = 0xFF'0FDEBD;
	
	// Convert scalar radii to vectors
	SkVector radii_2d[4];
	
	SkScalar const* in_begin = &radii[0];
	SkScalar const* in_end = &radii[4];
	SkVector* out_begin = &radii_2d[0];
	
	std::transform(in_begin, in_end, out_begin,
		[](SkScalar r) -> SkVector {
			return { r, r };
		});
	
	// Prepare our paint and canvas
	SkPaint paint;
	paint.setAntiAlias(true);
	paint.setStyle(SkPaint::kFill_Style);
	
	canvas->clear(SK_ColorTRANSPARENT);
	
	// Draw outer RRect  
	SkRRect outer;
	outer.setRectRadii(SkRect::MakeWH(width, height), radii_2d);
	
	paint.setColor(fillColor);
	canvas->drawRRect(outer, paint);
}

Here’s the result , which is as expected. To draw the border, we will still need to create a path, but instead of tracing it manually, we can build it from SkRRects. First, we need to build the inner SkRRect with the correct bounds and radii. We can use, with one minor adjustment, the formulas we came up with earlier (and write a similar helper function).

// ...

SkVector MakeInnerRadius(
	SkScalar outerRadius,
	SkScalar bx,
	SkScalar by)
{
	return {
		fmaxf(outerRadius - bx, 0),
		fmaxf(outerRadius - by, 0),
	};
}

void draw(SkCanvas* canvas) {
	// ...
	
	// Convert scalar radii to vectors
	SkVector outerRadii[4];
	
	SkScalar const* in_begin = &radii[0];
	SkScalar const* in_end = &radii[4];
	SkVector* out_begin = &outerRadii[0];
	
	std::transform(in_begin, in_end, out_begin,
		[](SkScalar r) -> SkVector {
			return { r, r };
		});
	
	// Compute inner radii
	SkVector innerRadii[4] {
		MakeInnerRadius(radii[0], borders.left, borders.top),
		MakeInnerRadius(radii[1], borders.right, borders.top),
		MakeInnerRadius(radii[2], borders.right, borders.bottom),
		MakeInnerRadius(radii[3], borders.left, borders.bottom),
	};
	
	// ...
	
	// Draw outer RRect  
	SkRRect outer;
	outer.setRectRadii(SkRect::MakeWH(width, height), outerRadii);
	
	paint.setColor(borderColor);
	canvas->drawRRect(outer, paint);
	
	// Draw inner RRect
	auto innerBounds = SkRect::MakeXYWH(
		borders.left,
		borders.top,
		width - (borders.left + borders.right),
		height - (borders.top + borders.bottom));
	
	SkRRect inner;
	inner.setRectRadii(innerBounds, innerRadii);
	
	paint.setColor(fillColor);
	canvas->drawRRect(inner, paint);
}

That gets us the correct result for opaque borders , so we just need to build a path from the two SkRRects to get the correct alpha blending for semi-transparent borders:

// ...

void draw(SkCanvas* canvas) {
	// ...
	SkColor borderColor = 0x80'0FDEBD;
	// ...
	
	// Create the outer shape
	SkRRect outer;
	outer.setRectRadii(SkRect::MakeWH(width, height), outerRadii);
	
	// Create the inner shape
	auto innerBounds = SkRect::MakeXYWH(
		borders.left,
		borders.top,
		width - (borders.left + borders.right),
		height - (borders.top + borders.bottom));
	
	SkRRect inner;
	inner.setRectRadii(innerBounds, innerRadii);
	
	// Paint the fill color
	paint.setColor(fillColor);
	canvas->drawRRect(outer, paint);
	
	// Construct a path for the border area
	SkPath border;
	border.addRRect(outer);
	border.addRRect(inner, SkPathDirection::kCCW);
	
	// Paint the border color
	paint.setColor(borderColor);
	canvas->drawPath(border, paint);
}

And that’s it ! We have a comprehensive Skia implementation of a rounded rectangle with independent corner radii and border widths. Now we can bring it into Unreal Engine.

Implementing the UMG widget

We’ll start with the basic boilerplate:

Public/CanvasRect.h
#pragma once

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

#include "CanvasRect.generated.h"


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

#include "SCanvasWidget.h"


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

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

For our UPropertys, I’m sorely tempted to try out the new UMG Viewmodel plugin , but that would add an additional plugin dependency to our own plugin, and one that’s in Beta at that. I also haven’t had the chance to give it a test drive yet, so I’m not sure if it would be a good fit for this project, and I’m definitely not equipped to give it a detailed write-up in this (already long) post. So we’ll save that investigation for another time. For now, we’ll just manually implement some getters and setters so we can request a redraw when the properties change.

We’ll create UStructs for the corner radii and border widths. We could have just used FVector4f for these, but I like the ergonomics of having meaningfully-named fields here. Using custom structs also gives us a convenient hook to customize the UMG details panel for these properties, which we’ll want to do eventually to make it easier to set them to uniform values.

Public/CanvasRect.h
// ...

USTRUCT(BlueprintType)
struct API FCanvasRectCorners
{
	GENERATED_BODY()
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float TopLeft = 0;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float TopRight = 0;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float BotRight = 0;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float BotLeft = 0;
};


USTRUCT(BlueprintType)
struct API FCanvasRectBorders
{
	GENERATED_BODY()
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float Left = 0;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float Top = 0;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float Right = 0;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float Bottom = 0;
};

// ...

Add equality-checking utilities to both for good measure:

Public/CanvasRect.h
// ...

USTRUCT(BlueprintType)
struct API FCanvasRectCorners
{
	// ...
	
	FORCEINLINE bool IsNearlyEqual(const FCanvasRectCorners& other) const
	{
		return FMath::IsNearlyEqual(other.TopLeft, TopLeft)
			&& FMath::IsNearlyEqual(other.TopRight, TopRight)
			&& FMath::IsNearlyEqual(other.BotRight, BotRight)
			&& FMath::IsNearlyEqual(other.BotLeft, BotLeft);
	}
	
	FORCEINLINE bool operator==(const FCanvasRectCorners& other) const
	{
		return other.TopLeft == TopLeft
			&& other.TopRight == TopRight
			&& other.BotRight == BotRight
			&& other.BotLeft == BotLeft;
	}
	
	FORCEINLINE bool operator!=(const FCanvasRectCorners& other) const
	{
		return !(operator==(other));
	}
};

// ...

Then fill out the class properties:

Public/CanvasRect.h
// ...

UCLASS()
class API UCanvasRect : public UCanvasWidget
{
	GENERATED_BODY()
	
private:
	using Self = UCanvasRect;
	
	UPROPERTY(EditAnywhere, meta=(AllowPrivateAccess), Category="Appearance",
		BlueprintGetter="FillColor",
		BlueprintSetter="SetFillColor")
	FColor _FillColor = FColor::Transparent;
	
	UPROPERTY(EditAnywhere, meta=(AllowPrivateAccess), Category="Appearance",
		BlueprintGetter="CornerRadius",
		BlueprintSetter="SetCornerRadius")
	FCanvasRectCorners _CornerRadius;
	
	UPROPERTY(EditAnywhere, meta=(AllowPrivateAccess), Category="Appearance",
		BlueprintGetter="BorderColor",
		BlueprintSetter="SetBorderColor")
	FColor _BorderColor = FColor::Transparent;
	
	UPROPERTY(EditAnywhere, meta=(AllowPrivateAccess), Category="Appearance",
		BlueprintGetter="BorderWidth",
		BlueprintSetter="SetBorderWidth")
	FCanvasRectBorders _BorderWidth;
	
public:
	UFUNCTION(BlueprintGetter) FColor FillColor() const { return _FillColor; }
	UFUNCTION(BlueprintGetter) const FCanvasRectCorners& CornerRadius() const { return _CornerRadius; }
	UFUNCTION(BlueprintGetter) FColor BorderColor() const { return _BorderColor; }
	UFUNCTION(BlueprintGetter) const FCanvasRectBorders& BorderWidth() const { return _BorderWidth; }
	
	UFUNCTION(BlueprintSetter) void SetFillColor(FColor in_fillColor);
	UFUNCTION(BlueprintSetter) void SetCornerRadius(const FCanvasRectCorners& in_cornerRadius);
	UFUNCTION(BlueprintSetter) void SetBorderColor(FColor in_borderColor);
	UFUNCTION(BlueprintSetter) void SetBorderWidth(const FCanvasRectBorders& in_borderWidth);
	
	// ...
};
Private/CanvasRect.cpp
// ...

void UCanvasRect::SetFillColor(FColor in_fillColor)
{
	if (_FillColor == in_fillColor)
		return;
	
	_FillColor = in_fillColor;
	
	if (SlateWidget.IsValid())
		SlateWidget->RequestRedraw();
}

void UCanvasRect::SetCornerRadius(const FCanvasRectCorners& in_cornerRadius)
{
	if (_CornerRadius.IsNearlyEqual(in_cornerRadius))
		return;
	
	_CornerRadius = in_cornerRadius;
	
	if (SlateWidget.IsValid())
		SlateWidget->RequestRedraw();
}

void UCanvasRect::SetBorderColor(FColor in_borderColor)
{
	if (_BorderColor == in_borderColor)
		return;
	
	_BorderColor = in_borderColor;
	
	if (SlateWidget.IsValid())
		SlateWidget->RequestRedraw();
}

void UCanvasRect::SetBorderWidth(const FCanvasRectBorders& in_borderWidth)
{
	if (_BorderWidth.IsNearlyEqual(in_borderWidth))
		return;
	
	_BorderWidth = in_borderWidth;
	
	if (SlateWidget.IsValid())
		SlateWidget->RequestRedraw();
}

// ...

Whew. Gotta love that Unreal boilerplate. I really wish we could write UHT extensions from user code, because that is begging to be its own UProperty specifier, but hopefully UMG Viewmodel and its FieldNotify specifier will ease some of the burden down the road.

On to the meat of the implementation. We’ll start by converting a few variables to Skia-friendly types.

Private/CanvasRect.cpp
// ...

void UCanvasRect::OnDraw(SkCanvas& canvas, FInt32Vector2 size, FVector2f scale)
{
	float width = size.X;
	float height = size.Y;
	SkVector skScale { scale.X, scale.Y };
	
	SkColor fillColor = _FillColor.ToPackedARGB();
	SkColor borderColor = _BorderColor.ToPackedARGB();
	
	// TODO
}

Note that SkScalar is just a typedef for float. We could use SkScalar instead of float here, under the assumption that maybe one day they’ll be doubles, or maybe there’s some preprocessor definition that can change it to double and maybe one day we’ll want to use that. But UHT is finicky about parsing UProperty declarations typed with non-Unreal typedefs, so we have to use the concrete type in a bunch of places anyway. But we will use SkColor (a typedef for uint32) since it helpfully clarifies what the type represents.

Next up is transforming the corner radii scalars to vectors. We could make FCanvasRectCorners iterable by defining float* begin() and float* end() methods, and then use Unreal’s std::transform equivalent like this:

Private/CanvasRect.cpp
// ...

void UCanvasRect::OnDraw(SkCanvas& canvas, FInt32Vector2 size, FVector2f scale)
{
	// ...
	
	TArray<SkVector,TInlineAllocator<4>> outerRadii;
	Algo::Transform(_CornerRadius, outerRadii, [skScale](float r) -> SkVector
	{
		return skScale * r;
	});
	
	// TODO
}

But that’s a pretty wildly overengineered solution, with a non-zero runtime cost (exacerbated by needing to capture skScale into our lambda) without any tangible benefit, so we’ll just transform them inline instead.

Private/CanvasRect.cpp
// ...

void UCanvasRect::OnDraw(SkCanvas& canvas, FInt32Vector2 size, FVector2f scale)
{
	// ...
	
	SkVector outerRadii[4] {
		skScale * _CornerRadius.TopLeft,
		skScale * _CornerRadius.TopRight,
		skScale * _CornerRadius.BotRight,
		skScale * _CornerRadius.BotLeft,
	};
	
	// TODO
}

Importantly though: however it gets done, we need to multiply all user-provided properties by the scale parameter before using them to draw anything. This is mainly because of Slate/UMG’s DPI-scaling features. We want designers to be able to specify those properties in “Slate units,” which are basically abstracted, hardware-agnostic pixel-equivalents (much like the CSS px unit).

On a standard 1080p display, scale will usually be 1. But on, say, a 4k display, it will be closer to 2. That means that a widget with a “desired size” of 300x100 will be drawing closer to 600x200 actual hardware pixels. The size parameter already has that scaling baked in, because it matches the render target’s size, which is mapped 1:1 to hardware pixels. But features like border widths, corner radii, or anything else that we expect the user to specify in Slate units, will need to be scaled to match.

Beyond that, the MVP is pretty much a 1:1 translation of our Skia fiddle. Here’s the bird’s-eye view, excluding the boilerplate we wrote earlier:

Private/CanvasRect.cpp
#include "CanvasRect.h"

#include "SCanvasWidget.h"
#include "include/core/SkCanvas.h"
#include "include/core/SkColor.h"
#include "include/core/SkPaint.h"
#include "include/core/SkPath.h"
#include "include/core/SkPoint.h"
#include "include/core/SkRRect.h"

// ...

void UCanvasRect::OnDraw(SkCanvas& canvas, FInt32Vector2 size, FVector2f scale)
{
	float width = size.X;
	float height = size.Y;
	SkVector skScale { scale.X, scale.Y };
	
	SkColor fillColor = _FillColor.ToPackedARGB();
	SkColor borderColor = _BorderColor.ToPackedARGB();
	
	// Scale outer radii
	SkVector outerRadii[4] {
		skScale * _CornerRadius.TopLeft,
		skScale * _CornerRadius.TopRight,
		skScale * _CornerRadius.BotRight,
		skScale * _CornerRadius.BotLeft,
	};
	
	// Compute inner radii
	SkVector innerRadii[4] {
		MakeInnerRadius(_CornerRadius.TopLeft, _BorderWidth.Left, _BorderWidth.Top, scale),
		MakeInnerRadius(_CornerRadius.TopRight, _BorderWidth.Right, _BorderWidth.Top, scale),
		MakeInnerRadius(_CornerRadius.BotRight, _BorderWidth.Right, _BorderWidth.Bottom, scale),
		MakeInnerRadius(_CornerRadius.BotLeft, _BorderWidth.Left, _BorderWidth.Bottom, scale),
	};
	
	// Prepare paint and canvas
	SkPaint paint;
	paint.setAntiAlias(true);
	paint.setStyle(SkPaint::kFill_Style);
	
	canvas.clear(SK_ColorTRANSPARENT);
	
	// Create the outer shape
	SkRRect outer;
	outer.setRectRadii(SkRect::MakeWH(width, height), outerRadii);
	
	// Create the inner shape
	auto innerBounds = SkRect::MakeXYWH(
		_BorderWidth.Left * scale.X,
		_BorderWidth.Top * scale.Y,
		width - (_BorderWidth.Left + _BorderWidth.Right) * scale.X,
		height - (_BorderWidth.Top + _BorderWidth.Bottom) * scale.Y);
	
	SkRRect inner;
	inner.setRectRadii(innerBounds, innerRadii);
	
	// Paint the fill color
	paint.setColor(fillColor);
	canvas.drawRRect(outer, paint);
	
	// Construct a path for the border area
	SkPath border;
	border.addRRect(outer);
	border.addRRect(inner, SkPathDirection::kCCW);
	
	// Paint the border color
	paint.setColor(borderColor);
	canvas.drawPath(border, paint);
}

SkVector UCanvasRect::MakeInnerRadius(float outerRadius, float bx, float by, FVector2f scale)
{
	return {
		FMath::Max((outerRadius - bx) * scale.X, 0),
		FMath::Max((outerRadius - by) * scale.Y, 0),
	};
}

And here’s the result, rendering in UMG:

Canvas Rect widget rendering in Unreal Engine's UMG designer, configured to look like the example from the top of this post.

Any of those properties in the Details panel on the right can be edited, and the preview will update in real-time. Try it out!

What’s next?

There are plenty of optimizations we could make at this point. For example, as we observed earlier, if we’re not rendering semi-transparent borders, we can just render two SkRRects on top of each other instead of creating a compound path for the border shape. If the border is zeroed out or fully transparent, we can skip one of the SkRRects altogether. If the corner radii are zeroed out, we can just use a plain SkRect (or for that matter, just clear the canvas with the fill color). I’ll leave all of that as an exercise for the reader (which is to say, this post has gone on long enough and it’s time for me to move on).

In the next entry in this series, we’ll explore using Skia to render SVG images in UMG.