One of the advantages of QWidgets when building a Qt application is the ability to build in a simple way custom widgets with the QPainter API. This gives the Qt developer almost total freedom to implement complex geometries for their widgets.
On the other hands, QML contains by default only rectangles. These rectangles can change the radius to create circles and rounded rectangles, but more complex shapes are more complicated.
The current state of custom geometry in QtQuick
Fortunally, the Qt API provides multiple ways to implement custom shapes, that depending on the needs might be enough.
There is the Canvas API using the same API as the canvas
API on the web but
in QML. It’s easy to use but very slow and I wouldn’t recommend it.
Instead of the Canvas API, from the QML side, there is the QtQuick Shapes module. This module allows creating more complex shapes directly from the QML with a straightforward declarative API. In many cases, this is good enough for the application developer but this module doesn’t offer a public C++ API.
If you need more controls, using C++ will be required to implement custom
QQuickItem. Unfortunately drawing
on the GPU using QQuickItem is more
complex than the QPainter API. You can’t just use commands like drawRect
,
but will need to convert all your shapes in triangles first. This involves
a lot of maths like it can be seen in the example from the
official documentation or from
the KDAB tutorial (Efficient custom shapes in Qt Quick).
A QPainter way is also available with QQuickPaintedItem, but it is slow because it renders your shape in a textured rectangle in the Scene Graph.
The Rusty way
What if we could transform arbitrary shapes into triangles? We would get a high level API but still get great performance. This process is called tessellation and there are a few libraries that implement it. For example in C++, we have Skia and CGAL. Unfortunatelly, both aren’t easy to use, so I decided to look at the Rust library ecosystem and in particular at Lyon, which was designed with performance and compliance to the SVG standard in mind since the goal is to use it in Servo in the future.
Lyon doesn’t have any C++ bindings but I got inspired by the recent blog post from Jonah and I need to say the experience of writing bindings was a breeze.
The first step was creating wrapper struct
s around the
Lyon primitives. LyonPoint
, LyonGeometry
and LyonBuilder
will
later be directly usable from the C++ side.
#[cxx::bridge]
mod ffi {
pub struct LyonPoint {
x: f32,
y: f32,
}
pub struct LyonVector {
x: f32,
y: f32,
}
pub struct LyonGeometry {
vertices: Vec<LyonPoint>,
indices: Vec<u16>,
}
extern "Rust" {
type LyonBuilder;
fn new_builder() -> Box<LyonBuilder>;
fn move_to(self: &mut LyonBuilder, point: &LyonPoint);
fn line_to(self: &mut LyonBuilder, point: &LyonPoint);
fn relative_move_to(self: &mut LyonBuilder, to: LyonVector);
fn close(self: &mut LyonBuilder);
fn quadratic_bezier_to(self: &mut LyonBuilder, ctrl: &LyonPoint, to: &LyonPoint);
fn cubic_bezier_to(self: &mut LyonBuilder, ctrl1: &LyonPoint, ctrl2: &LyonPoint, to: &LyonPoint);
fn build_fill(builder: Box<LyonBuilder>) -> LyonGeometry;
fn build_stroke(builder: Box<LyonBuilder>) -> LyonGeometry;
}
}
We then need to define the methods we declared above. These are all trivial to implement since they are just wrapping the Lyon API.
use ffi:{LyonPoint, LyonVector, LyonGeometry};
// Create a wrapper arround Lyon svg path. This struct is opaque from
// the C++ side so we won't be able to access the internal object, but
// we still can call the methods on it.
pub struct LyonBuilder {
builder: WithSvg<Builder>,
}
// Implement wrapping methods
impl LyonBuilder {
fn close(&mut self) {
self.builder.close();
}
fn move_to(&mut self, to: &LyonPoint) {
self.builder.move_to(point(to.x, to.y));
}
fn line_to(&mut self, to: &LyonPoint) {
self.builder.line_to(point(to.x, to.y));
}
fn quadratic_bezier_to(&mut self, ctrl: &LyonPoint, to: &LyonPoint) {
self.builder.quadratic_bezier_to(point(ctrl.x, ctrl.y), point(to.x, to.y));
}
...
}
// Lyon Builder constructor
pub fn new_builder() -> Box<LyonBuilder> {
return Box::new(LyonBuilder{
builder: Path::builder().with_svg()
})
}
The next step was to add the build_fill
that will transform the
SVG path instructions into a set of vertices and indices. These vertices
and indices will be directly available from the C++ side. This is extremely
handy since this can be directly fed into the QSGGeometry
painting method.
pub fn build_fill(builder: Box<LyonBuilder>) -> LyonGeometry {
let mut buffers: VertexBuffers<Point, u16> = VertexBuffers::new();
{
let mut vertex_builder = simple_builder(&mut buffers);
// Create the tessellator.
let mut tessellator = FillTessellator::new();
let path = builder.builder.build();
// Compute the tessellation.
let result = tessellator.tessellate_path(
&path,
&FillOptions::tolerance(0.01),
&mut vertex_builder
);
assert!(result.is_ok());
}
LyonGeometry {
// convert_points transform lyon::point to our LyonPoint wrapper
vertices: convert_points(buffers.vertices),
indices: buffers.indices,
}
}
And we are almost done with the Rust side, we still need to create the cargo and corrosion configuration, but I won’t go into details in this post. You can look at how it was done in this pet project.
Using the generated bindings
To make it easy to store and manipulate the path, I create a simple abstraction to the various SVG path instructions.
#include <QList>
#include <variant>
#include <tessellation.rs.h>
/// Move to the point without drawing a line.
struct MoveTo
{
/// The destination.
LyonPoint to;
};
/// Drawe a line to a specific point.
struct LineTo
{
/// The destination.
LyonPoint to;
};
/// Draw a cubic bezier curve to the point.
struct CubicBezierTo
{
/// First control point.
LyonPoint ctrl1;
/// Second control point.
LyonPoint ctrl2;
/// The destination.
LyonPoint to;
};
/// Close a path.
struct Close
{};
/// SVG conform path commands
using PathSection = std::variant<MoveTo, LineTo, CubicBezierTo, Close>;
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
/// The SVG path data. It contains a list of instruction (move to, line to, ...).
using PathData = QList<PathSection>;
Now let finally use Lyon to generate the geometry primitives. This will need to be called every time the list of commands is updated. It’s using the command abstraction, I build previously, but this could directly call the LyonBuilder methods.
const auto commands << MoveTo { LyonPoint{0.0, 0.0} }
<< LineTo { LyonPoint{0.0, 40.0} }
<< LineTo { LyonPoint{40.0, 40.0} }
<< CubicBezierTo{ LyonPoint{70.0, 40.0}, LyonPoint{70.0, 0.0}, LyonPoint{ 50.0, 20.0} }
<< LineTo { LyonPoint{40.0, 0.0} }
<< Close {};
auto lyonBuilder = new_builder();
for (const auto &command: commands) {
std::visit(overloaded {
[&lyonBuilder](MoveTo moveTo) { lyonBuilder->move_to(moveTo.to); },
[&lyonBuilder](LineTo lineTo) { lyonBuilder->line_to(lineTo.to); },
[&lyonBuilder](CubicBezierTo cubicBezierTo) { lyonBuilder->cubic_bezier_to(cubicBezierTo.ctrl1, cubicBezierTo.ctrl2, cubicBezierTo.to); },
[&lyonBuilder](Close) { lyonBuilder->close(); },
}, command);
}
auto m_geometry = build_fill(std::move(lyonBuilder));
And finally here is our updatePaintNode
method. It’s using the GL_TRIANGLES
drawing mode and the vertices and indices are copied directly from the geometry
Lyon gave us.
QSGNode *PathItem::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *)
{
QSGGeometryNode *node = nullptr;
QSGGeometry *geometry = nullptr;
if (!oldNode) {
node = new QSGGeometryNode;
geometry = new QSGGeometry(QSGGeometry::defaultAttributes_Point2D(),
m_geometry.vertices.size(), m_geometry.indices.size());
geometry->setIndexDataPattern(QSGGeometry::StaticPattern);
geometry->setDrawingMode(GL_TRIANGLES);
node->setGeometry(geometry);
node->setFlag(QSGNode::OwnsGeometry);
QSGFlatColorMaterial *material = new QSGFlatColorMaterial;
material->setColor(QColor(255, 0, 0));
node->setMaterial(material);
node->setFlag(QSGNode::OwnsMaterial);
} else {
node = static_cast<QSGGeometryNode *>(oldNode);
geometry = node->geometry();
geometry->allocate(m_geometry.vertices.size(), m_geometry.indices.size());
}
QSGGeometry::Point2D *points = geometry->vertexDataAsPoint2D();
std::size_t i = 0;
for (const auto &vertice: m_geometry.vertices) {
points[i].set(vertice.x, vertice.y);
i++;
}
quint16* indices = geometry->indexDataAsUShort();
i = 0;
for (const auto indice: m_geometry.indices) {
indices[i] = indice;
i++;
}
node->markDirty(QSGNode::DirtyGeometry);
return node;
}
It is only using Lyon SVG path rendering, but Lyon provides a lot more APIs. For example, there is an abstraction that allows to draw circle, ellipse, rounded rectangle and other basic geometric forms.
There is also the possibility to add custom attributes for texture coordinate or color coordinate. Depending on your need more part of the API could be wrapped and I might create a small library wrapping most of the API.
Custom shape in action
I used this technique in a new toy I’m building. I’m not sure where it is going, but I currently have this:
Comments
With an account on the Fediverse or Mastodon, you can respond to this post. Since Mastodon is decentralized, you can use your existing account hosted by another Mastodon server or compatible platform if you don't have an account on this one. Known non-private replies are displayed below.
Learn how this is implemented here.