ClaFi Graphics

One canvas, drawn by the CPU or by the GPU

Introduction

Everything a window shows is drawn on its canvas: a Graphics::Canvas the form owns and hands to every paint through the event. A caller states shapes, paths and brushes in the surface's pixels, and the canvas passes each one to the backend behind it - the CPU rasterizer that comes with the core, or Direct2D on Windows. Which of the two draws is the user's choice, made in Settings while the application runs, so a control does not know which one it is drawn by and must not need to.

Both backends answer to one contract: the drawing API promises nothing one of them cannot draw. A translucent brush is a colour with alpha, on both, and nothing beside a brush carries an opacity of its own. What the two still draw differently is listed in Backend differences, each with what to keep to so that a picture comes out the same from either.

What a caller draws with:

Module imports: the canvas and its scoped guards are in ClaFi.Core.Graphics.Canvas. Brushes, PixelPath, PathDrawLayer, Matrix3x2, ShadowParams, Bitmap, PixelView and IBackend are in ClaFi.Core.Graphics.Types. Color, FloatRect and RoundedRectangleParts come with ClaFi.Core.System.UiTypes. The CPU backend is ClaFi.Core.Graphics.Cpu.Canvas and the Direct2D one ClaFi.Platform.Windows.D2D; a caller drawing in a paint handler imports neither.

Quick Start

A paint handler takes the canvas from its event and draws in the rect the event hands it. An icon's PaintIconEvent answers the canvas, the icon's rect, the stroke weights and the inks; a control's PaintEvent answers the same.

void paintAddIcon(PaintIconEvent& event)
{
    Canvas& canvas = event.canvas();
    const FloatRect& box = event.iconRect();
    const float size = std::min(box.width(), box.height());
    const float stroke = event.scaledStrokeWidth(Thickness::Regular);

    // The frame. Its stroke lies inside the rect it is given.
    const float radius = size * 0.2f;
    canvas.drawRoundedRectangle(box, radius, radius, event.textRgb(InkGrade::Muted), stroke);

    // The plus, as a path in the icon's own coordinates placed by a translation.
    PixelPath path;
    path.moveTo(size * 0.5f, size * 0.28f);
    path.lineTo(size * 0.5f, size * 0.72f);
    path.moveTo(size * 0.28f, size * 0.5f);
    path.lineTo(size * 0.72f, size * 0.5f);
    const Matrix3x2 place = Matrix3x2::translation(box.topLeft());
    const Color mark = event.accentRgb(InkGrade::Strongest);
    canvas.drawPath(path, { PathDrawLayer::stroke(mark, stroke) }, &place);
}

Colours come from the event rather than from the code: an ink is resolved against the surface it is drawn on, which is the Colour manual's subject. Everything below is what happens to a colour once it is in hand.

Core Concepts

The canvas and its backend

A canvas is a thin layer over an IBackend it owns. It keeps the clip rectangles and the transform it was told, builds out of paths the few shapes a backend may decline - a partial rounded rectangle, a shadow - and forwards everything else. The backend is what turns a call into pixels, and it is the only part that differs between the CPU and the GPU.

A form's canvas is built by the application and drawn in the form's paint passes, so a paint handler never begins or ends a frame. A canvas of one's own - a picture baked once and cut up, say - is built over a CPU backend, which A canvas of your own shows.

Coordinates

The canvas addresses the surface in its device pixels, from the top left corner, y growing downwards. The rects a paint event hands out are in those pixels already. A size written at design - a radius, a gap, a reach - becomes one through scaleF, and a line's weight through scaledStrokeWidth:

const float radius = event.scaleF(6.0f);                         // a design size in device pixels
const float line = event.scaledStrokeWidth(Thickness::Thin);    // whole pixels, at least one

The weights run Hairline, Thin, Regular, Bold and Heavy. scaledStrokeWidth answers whole pixels, at least one, and from Thin up each grade is a pixel wider than the one below - so a line keeps its weight against its neighbours at every scale the application is drawn at.

Strokes lie inside

Every outline lays its stroke inside the bounds it is given. A rectangle, a rounded rectangle, an ellipse and a partial rounded rectangle drawn over the same bounds as their fill meet it exactly: the outer edge of the stroke is the edge of the shape, and no fill shows past the border.

canvas.fillRoundedRectangle(card, radius, radius, surface);
canvas.drawRoundedRectangle(card, radius, radius, border, line);   // the same rect, not an inset one
Do not inset the bounds by half the stroke. The backend does that itself - Direct2D strokes centred on a shape, so it moves the shape in - and a caller doing it as well leaves half a pixel of fill outside every border.

A line and a path have no inside. Their stroke straddles the line or the path it follows, half on each side.

drawRoundedRectangle(rect, ...) the outer edge is the bounds, and a fill of the same rect meets it what Direct2D is handed the centreline, half a stroke in, its radii shrunk by as much drawLine, drawPath no inside: the stroke straddles the line, half on each side

Brushes

A Brush is what a shape is filled or stroked with, one of four kinds. Every call that takes a brush also takes a Color, which is a solid brush.

BrushPaints
SolidColorOne colour. Its alpha is how much of what is underneath shows through.
LinearGradientColour stops along a line from startPoint to endPoint, the same across it. Before the first stop and past the last the end colour holds.
RadialGradientColour stops from the origin, center moved by offset, out to the ellipse of radiusX and radiusY round center. Past the ellipse the last colour holds.
PointGlowA point of light: its colour at lightPos, thinning straight to nothing at lightSpread.
SolidColor LinearGradient RadialGradient PointGlow startend origincenter one colour, its alphaletting the ground through stops from start to end,the end colours past them stops from the originout to the ellipse a light at lightPos,thinning straight to nothing

A brush is stated in the coordinates of the shape it fills. For a shape that is the canvas's coordinates; for a path drawn under a transform it is the path's own, so a gradient across a path moves, scales and turns with it.

Stop positions run from 0 to 1 and ascend. A gradient of two colours has a shorthand:

canvas.fillRectangle(band, LinearGradient::simple(band.topLeft(), band.bottomLeft(), top, bottom));
canvas.fillTopBottomGradientRectangle(band, top, bottom);   // the same thing

A radial gradient's origin lies inside its ellipse, and both radii are above zero. An origin on the ellipse or past it, or a radius of zero, is undefined behaviour.

A point of light lights whatever it is painted on. Its spread is the vertical radius, and xRatio divides it for the horizontal one, so 0.5 is a light twice as wide as it is tall. How strong the light is, is its colour's alpha:

canvas.fillRectangle(bounds, PointGlow{
    .lightPos = bounds.center(),
    .lightColor = lamp.withOpacity(0.4f),
    .lightSpread = event.scaleF(60.0f),
    .xRatio = 0.5f
});
lightSpread lightSpread / xRatio lightPos xRatio = 0.5: twice as wide as tall alpha along any radius lightColor.alpha lightPos the spread straight to nothing, and nothing past it
A glow is a circle. PointGlow::shape also names SoftRectangle and SharpRectangle, which only the CPU backend draws; Direct2D draws every point of light as the ellipse GlowShape::Circle draws.

Paths and layers

A PixelPath is a list of commands - move, line, quadratic and cubic curve, elliptical arc, close - each absolute or relative, as SVG's path data has them. A moveTo starts a new figure, and close joins a figure's end back to its start; a segment drawn on after a close, with no move between, starts a new figure at that same start. A path starts with a move: anything drawn before its first moveTo is undefined behaviour. An arc is stated as SVG states one: the ellipse's radii and its rotation in degrees, ArcSize::Small or Large for which of the two arcs joining its ends it takes, and ArcSweep::Clockwise or CounterClockwise for the way it turns on the screen. Radii too small to reach its end grow together until they do, a radius of zero makes it a line, and an arc ending where it starts draws nothing. Three helpers build a whole figure and start a fresh path to do it: drawCircle(center, radius), drawEllipse(center, radiusX, radiusY), and drawRoundedRect(width, height, radius), which is centred on the origin, or drawRoundedRect(rect, radiusX, radiusY), which is SVG's rect, each radius cut to half its own side. addRoundedPolygon appends a polygon with its corners rounded, keeping what the path already holds.

A curve is drawn to within half a pixel of itself. A path keeps its curves as curves, and each backend turns them into what it rasterizes: Direct2D tessellates them itself, and the CPU backend cuts each one into straight segments, halving it until every piece lies within half a device pixel of its chord. An arc goes to Direct2D as an arc; the CPU backend first makes it cubics of at most a quarter turn each, none further from the ellipse than 0.03 percent of its radius. The half pixel is measured on the screen, the path's transform and the canvas's included, so a curve drawn larger is cut into more segments and none comes out coarser. No call takes a precision.

r = 10 px 4 segments, shown at 11.6x r = 40 px 8 segments, shown at 2.9x r = 160 px 16 segments, shown at 0.7x r = 640 px 32 segments, shown at 0.2x One rounded corner - a quarter circle as one cubic - at four sizes on the screen. The dashed red line is the curve; every segment stays within half a device pixel of it, so the larger the corner, the more segments.

A path is drawn as a stack of layers, each a PathDrawLayer: a mode, the geometry options the mode reads, and a brush. The layers are drawn in the order given, so a later one covers an earlier one.

ModeDraws
PathRenderMode::FillThe area the figures enclose, even-odd: a figure inside another is a hole.
PathRenderMode::StrokeA line of strokeWidth along every figure, straddling it, its open ends finished by strokeCap.
PathRenderMode::OuterGlowA ramp out from the outline to strokeWidth, as far in as out. What covers the inward half is the shape drawn over it.
PathRenderMode::ShadowThe shape's own coverage, solid, and the ramp outward from it to strokeWidth - what an opaque shape casts.
PathRenderMode::Fill - even-odd a figure inside another is a hole strokeCap - how an open end finishes Round Butt Square Round and Square reach half a stroke past the end point

A glow's ramp thins by its GlowFalloff. Linear runs straight to nothing, which is what light wants: a lamp does not thin out, it runs out. Smooth is steep at the shape and level by the time it ends, which is what a shadow wants - a far end faint enough not to read as a border drawn round the shape.

1 0 the shape the reach Linear Smooth GlowFalloff::Linear straight to nothing - a lamp runs out GlowFalloff::Smooth steep at the shape, level by the end
// A badge: its glow first, so the fill covers the ramp's inward half, then the fill and its rim.
canvas.drawPath(badge, {
    PathDrawLayer::outerGlow(halo, event.scaleF(6.0f)),
    PathDrawLayer::fill(body),
    PathDrawLayer::stroke(rim, line)
}, &place);
outerGlow(halo, 6) both ways from the outline fill(body) covers the inward half stroke(rim, line) straddles the outline the three, in order the badge

The one-brush calls are a single layer: fillPath(path, brush) is a fill, and drawPath(path, brush, strokeWidth) a stroke with round ends. The transform a path is given places the geometry and the brush together, and scales its stroke widths and reaches with them.

Clips

A clip is a push and a pop. pushClip(rect) keeps what follows inside a rectangle; pushClip(path, transform) inside the area a path fills, antialiased at its edge. Clips nest, and each one pushed inside another is cut down to it.

canvas.pushClip(viewport);
paintRows(canvas);
canvas.popClip();
pushClip(rect) pushClip(path) inside it what is drawn shows where both allow it, the path's edge antialiased clipBox() the path's bounding box, cut by the rect faint: what the caller drew

clipBox() answers the rectangle the canvas has recorded for the clip in force - a path clip's bounding box, cut by the clips around it - which is what a painter asks to skip what cannot show. A frame's own dirty rectangle is the first clip of every frame, and the clips a frame leaves pushed are popped when it ends.

A path clip is not free. The CPU backend holds it as a coverage mask the size of its bounds, so a clip shaped like one corner of a window costs a corner's worth of mask and one shaped like the whole window a window's worth. Clip to the part that needs the shape.

Transforms

The canvas carries one Matrix3x2, applied to everything drawn. A caller composes onto it rather than replacing it, through ScopedCanvasTransform, which puts the previous one back when it goes:

// Grows what follows about a point. The matrix to the right applies first.
const Matrix3x2 grow = Matrix3x2::translation(origin)
    * Matrix3x2::scale(1.1f)
    * Matrix3x2::translation(-origin.x, -origin.y);
const ScopedCanvasTransform scaled{ canvas, grow };

The framework uses the same guard to scale a pressed control about its press origin, so a paint handler is already drawing under a transform at times, and draws the same way whether it is or not. The local matrix is applied first and the one in force after it, so a scaled control inside another lands where both put it.

A uniform scale and a translation draw the same on both backends. A rotation, a shear or a scale that differs between the axes is drawn exactly only by a path: the CPU backend maps a rectangle's top-left and bottom-right corners and draws the upright box between them, and measures radii and widths on the larger axis. Backend differences lists each case.

fillRectangle(rect) the rect, under no transform CPU, under rotation(20°) the upright box between the mapped top-left and bottom-right corners Direct2D, and a path on both the turned shape

Opacity layers

Everything drawn between pushOpacity and its pop reaches the surface as one image, at the layer's opacity. Two shapes overlapping inside a layer do not show each other through where they cross, and a shape drawn over another covers it as completely as it would at full strength. That is the reason a layer exists rather than an alpha folded into each brush: a group faded through its brushes shows its own background through its own text.

each brush at half alpha the circle shows through the square, and both let the ground through ScopedCanvasOpacity{ canvas, 0.5f } the square covers the circle as it would at full strength, and the whole fades
// One layer for the whole mark: its parts overlap, and faded one by one they would show.
const ScopedCanvasOpacity layer{ canvas, 1.0f - event.disabledAmount() };
paintLeaves(canvas, transform);
paintBlot(canvas, transform);

ScopedCanvasOpacity opens nothing at full opacity, so a caller with a factor that may reach 1 states it there rather than branching round the passage it guards. One shape fading on its own needs no layer: its colour's alpha is the same thing, at no cost.

Text inside a layer is antialiased in grayscale under Direct2D, where it is ClearType outside one: the layer starts empty, and a glyph drawn into it has no opaque ground to lay its subpixel colour against.

Shadows

castShadow draws the shadow a rounded rectangle casts on what is already drawn, and not the rectangle. Whatever casts a shadow covers it, so a caller casts the shadow first and draws the shape over it:

const RoundedRectangleParts card{ .bounds = bounds, .radii = uniformCorners(radius) };
const ShadowParams shadow{
    .color = event.inkRgb(InkWell::Black).withOpacity(0.3f),
    .blur = event.scaleF(12.0f),
    .offset = { 0.0f, event.scaleF(2.0f) }
};
canvas.castShadow(card, shadow);
canvas.fillPartialRoundedRectangle(card, event.surfaceRgb());

The colour is the shadow where it is darkest, against the shape's edge, so its alpha is the whole of how heavy the shadow reads. blur is how far the ramp reaches past the shape, offset which way the light falls, and spread how much the shape is grown before its shadow is taken - which moves the whole ramp out rather than stretching it, so a shadow can sit wider without growing softer. A round corner grows with the spread; a square one stays square.

the shape, drawn after the shadow castShadow(parts, shadow) the silhouette: the shape carried by offset and grown by spread, solid in the shadow's colour the silhouette's edge blur: the ramp, out to here the shape covers what is under it; a translucent fill shows it through
Under the shape the shadow is solid. The silhouette is filled in the shadow's colour and the ramp runs outward from its edge, so the shape drawn over it is what hides it. A shape with a translucent fill shows the shadow through, and wants its shadow clipped to the outside of it.

Kept images

A kept image is a picture the size of the surface that outlives the frame it was drawn in, named by an index. Everything drawn between pushImage(index) and its pop goes into it rather than onto the surface, and drawImage(index, opacity) puts it back at an opacity. A caller holding two can build the next picture of a crossfade out of the last one and trade them over.

if (!canvas.hasImage(0))
{
    const ScopedCanvasImage into{ canvas, 0 };
    paintScene(canvas);
}
canvas.drawImage(0, fade);

An image is drawn into under the canvas's transform and put back on the surface's own pixels, whatever transform is in force when it is. A new image is transparent, and a push does not clear it: what a caller leaves uncovered keeps what was there before.

The store belongs to the surface. A resize empties it, a switch of backend empties it, and so does a lost device under Direct2D. hasImage is how a caller finds out that what it drew is gone and has to be drawn again.

Pixels

A Bitmap is a block of pixels the caller owns, and drawBitmap(bitmap, position) draws it as it is. decodePng reads one out of a PNG file's bytes, from ClaFi.Core.Graphics.Png, and decodeDib and decodeBmpFile out of a bitmap's, from ClaFi.Core.Graphics.Dib. A PixelView is a window onto such a block - its bounds, its clip and its scan lines - and drawPixelView draws one.

stagingDraw goes the other way: it hands the caller a view to write pixels into itself, and draws what was written when the callback returns. Whatever the caller writes there is outside the canvas transform by construction, so the transform is handed in beside the view, in the coordinates the caller already works in:

canvas.stagingDraw(rect, [&](PixelView view, const Matrix3x2& canvasTransform) {
    const Matrix3x2 place = canvasTransform * Matrix3x2::translation(rect.topLeft());
    rasterize(view, place);
});

Pixels are where the backends differ most - what a view holds before the caller writes, whether a bitmap follows the transform, and how either meets what is already drawn. Backend differences has each of them, and the rule that keeps both the same: write opaque pixels, read none back, and draw a bitmap under no transform and no path clip.

Reference

Canvas

Every call that takes a Brush also takes a Color. A stroke width left out is one pixel.

CallDraws
fillRectangle(rect, brush)
drawRectangle(rect, brush, width)
A rectangle, or its outline inside it.
fillRoundedRectangle(rect, rx, ry, brush)
drawRoundedRectangle(rect, rx, ry, brush, width)
A rectangle with round corners, each a quarter of the ellipse the two radii make.
fillPartialRoundedRectangle(parts, brush)
drawPartialRoundedRectangle(parts, brush, width)
A radius per corner, and for an outline a choice of sides - see RoundedRectangleParts.
fillEllipse(center, rx, ry, brush)
drawEllipse(center, rx, ry, brush, width)
An ellipse.
fillCircle(center, radius, brush)
drawCircle(center, radius, brush, width)
A circle, the outline inside it.
drawLine(from, to, brush, width) A line with round ends, straddling the two points.
fillPath(path, brush, transform)
drawPath(path, brush, width, transform)
A path filled, or stroked with round ends.
drawPath(path, layers, transform) A path as a stack of PathDrawLayer, in order.
fillLinearGradientRectangle(rect, start, end, stops)
fillTopBottomGradientRectangle(rect, top, bottom)
fillLeftRightGradientRectangle(rect, left, right)
A rectangle under a linear gradient.
fillRadialGradientRectangle(rect, center, offset, radiusX, radiusY, stops) A rectangle under a radial gradient.
fadeEdge(rect, radii, side, size, color) A band along one side, the colour at the edge and clear size in. Its two corners on that edge take the rect's radii there, so a fade over a rounded view ends on its arc.
castShadow(parts, shadow) The shadow a rounded rectangle casts, and not the rectangle.
pushClip(rect), pushClip(path, transform), popClip(), clipBox() Clips - see Clips.
transform(), setTransform(m), resetTransform() The transform in force. Prefer ScopedCanvasTransform to setting it.
pushOpacity(o), popOpacity() An opacity layer. Prefer ScopedCanvasOpacity.
pushImage(i), popImage(), drawImage(i, o), hasImage(i) Kept images. Prefer ScopedCanvasImage to the pair.
drawBitmap(bitmap, position), drawPixelView(view) Pixels as they are.
stagingDraw(rect, callback) A view the callback writes pixels into, drawn when it returns.
width(), height(), backend() The surface's size, and the backend drawing it.
beginPaint, endPaint, resize, setBackend The frame and the surface. A form calls these; a canvas of your own calls the first two.

Brush

using Brush = std::variant<SolidColor, LinearGradient, RadialGradient, PointGlow>;

struct SolidColor { Color color; };
struct GradientStop { float position; Color color; };

struct LinearGradient
{
    FloatPoint startPoint;
    FloatPoint endPoint;
    std::vector<GradientStop> stops;
    static LinearGradient simple(FloatPoint start, FloatPoint end, Color startColor, Color endColor);
};

struct RadialGradient
{
    FloatPoint center;
    FloatPoint offset;     // where the stops start from, relative to the centre
    float radiusX;
    float radiusY;
    std::vector<GradientStop> stops;
};

struct PointGlow
{
    FloatPoint lightPos;
    Color lightColor;              // its alpha is the light's strength
    float lightSpread;             // the vertical radius
    float xRatio{ 1.0f };           // the horizontal radius is lightSpread / xRatio
    GlowShape shape{ GlowShape::Circle };
};

PathDrawLayer

struct PathGeometryOptions
{
    PathRenderMode mode = PathRenderMode::Fill;
    float strokeWidth = 1.0f;                       // a stroke's width, a glow's reach
    StrokeCap strokeCap = StrokeCap::Round;           // Round, Butt or Square
    GlowFalloff falloff = GlowFalloff::Linear;      // read by OuterGlow and Shadow
};

struct PathDrawLayer { PathGeometryOptions geometry; Brush brush; };
ShorthandLayer
PathDrawLayer::fill(color)A fill in a colour.
PathDrawLayer::stroke(color, width)A stroke with round ends.
PathDrawLayer::linearGradient(start, end, startColor, endColor)A fill under a two-stop gradient.
PathDrawLayer::pointGlow(glow)A fill under a point of light.
PathDrawLayer::outerGlow(color, reach, cap = Round, falloff = Linear)A glow both ways from the outline.
PathDrawLayer::shadow(color, reach, falloff = Smooth)The shape solid, and its ramp outward.

PixelPath

CallAdds
moveTo(p), moveBy(d)The start of a new figure. A path's first command; anything before it is undefined behaviour.
lineTo(p), lineBy(d), hLineTo(x), hLineBy(dx), vLineTo(y), vLineBy(dy)A straight segment.
quadTo(control, end), bezierBy(dControl, dEnd)A quadratic curve, absolute or relative.
cubicTo(c1, c2, end), cubicBy(d1, d2, dEnd)A cubic curve.
arcTo(radii, rotation, size, sweep, end), arcBy(radii, rotation, size, sweep, delta)An elliptical arc, as SVG's A and a: the rotation in degrees, an ArcSize and an ArcSweep.
close()The segment back to the figure's start. A segment after it starts a new figure there.
addRoundedPolygon(points, radius)A closed polygon with its corners rounded, each cut back by the radius or half its shorter edge.
drawRoundedRect(w, h, r), drawRoundedRect(rect, rx, ry), drawCircle(center, r), drawEllipse(center, rx, ry)A whole figure of lines and arcs, in a path cleared first. The first rectangle is centred on the origin; the second is SVG's rect, each radius cut to half its own side.
transform(m), clear()Every point moved through a matrix; nothing.

Matrix3x2

An affine transform, binary compatible with Direct2D's D2D1_MATRIX_3X2_F. In a * b, b applies first - the opposite order to Direct2D's own operator*, which matters only to code that mixes the two.

Matrix3x2::identity();
Matrix3x2::translation(dx, dy);    Matrix3x2::translation(point);
Matrix3x2::scale(s);               Matrix3x2::scale(sx, sy);
Matrix3x2::rotation(radians);     Matrix3x2::rotationDeg(degrees);

FloatPoint p = m.transform(point);         // a point
FloatPoint v = m.transformVector(vector);  // a direction, translation left out
FloatRect r = m.mapRect(rect);             // the upright box between the two mapped corners
float k = m.getScaleFactor();             // the larger of the two axes' scales

RoundedRectangleParts

struct RoundedRectangleParts
{
    FloatRect bounds;
    CornerRadii radii{ k_squareCorners };         // top left, top right, bottom right, bottom left
    RectSidesBoolArray sides{ k_allRectSidesTrue }; // top, right, bottom, left
};

A corner is round while its radius is above zero, and no corner reaches past the middle of the shorter side. uniformCorners(r) gives every corner the same radius.

sides says which edges an outline draws; a fill covers the whole shape whatever it holds. A corner arc is drawn while either side it joins is. An open end runs to the edge of the bounds rather than stopping at the outline, so two shapes sharing an edge - the cells of a grid - join into one unbroken line.

radii and sides, in their order radii[0] radii[1] = 0 radii[2] radii[3] sides[0] sides[1] sides[2] sides[3] top left, top right, bottom right, bottom left; top, right, bottom, left two cells sharing an edge A B B draws no left side; its top and bottom run to its bounds' edge, B's dashed, and meet A's border in one unbroken line

ShadowParams

struct ShadowParams
{
    Color color{};                                // the darkest point; its alpha is the shadow's weight
    float blur{ 0.0f };                             // how far the ramp reaches past the shape
    FloatPoint offset{};                          // which way the light falls
    float spread{ 0.0f };                           // how much the shape grows first
    GlowFalloff falloff{ GlowFalloff::Smooth };
};

A shadow with no blur, no alpha or empty bounds draws nothing.

Scoped guards

GuardFor as long as it lives
ScopedCanvasTransform{ canvas, local }Composes local onto the transform in force and puts the previous one back. An identity matrix touches nothing.
ScopedCanvasOpacity{ canvas, opacity }Holds an opacity layer open. Full opacity opens none.
ScopedCanvasImage{ canvas, index }Draws into the kept image at that index, and puts the surface back.

A canvas of your own

A picture drawn once and used many times - the window's shadow is baked this way and cut into tiles - is drawn on a canvas over a CPU backend, which needs nothing but a size. The frame is begun and ended as a form's is, and the end hands back the pixels:

auto backend = std::make_unique<Graphics::Cpu::CpuBackend>(size);
backend->setTransparentBase(true);   // start from nothing, not from what the buffer held
Graphics::Canvas canvas{ std::move(backend) };

const IntRect whole = { 0, 0, size.x, size.y };
canvas.beginPaint(nullptr, whole);
canvas.castShadow(silhouette, shadow);
Graphics::Bitmap* painted = nullptr;
canvas.endPaint(nullptr, whole, painted);   // the backend's own buffer, alive as long as the canvas

Draw between the two calls. A CPU canvas draws outside a frame as well, but nothing promises that of a backend, and Direct2D draws nothing there.

The Backends

Which backend draws

The CPU backend is always there. It is the core's own, it is the only one on Wayland, and it draws everything made off-screen whatever the platform. A GPU backend is the application's to name, in its type:

// Win32: Direct2D beside the CPU backend.
using Win32Application = Application<Win32Platform, Direct2DBackend>;

// Wayland: no GPU backend.
using WaylandApplication = Application<WaylandPlatform>;

An application that names one shows GPU acceleration on its Settings page, kept in its config and on by default. One that names none shows no such setting and keeps no answer about it. A change of the setting reaches every window at the top of its next frame, so a switch asked for from inside a click cannot land between the passes of a frame being painted.

canvas.backend()->type() answers BackendType::Cpu or BackendType::Direct2D. Asking is for platform code - the text engine asks, to reach DirectWrite's own glyph drawing. A control that draws one way on one backend and another way on the other is a gap in the contract, and belongs in the table below.

Backend differences

These are the calls the two backends draw differently. Each row is a gap in the contract rather than a feature of either backend, and the last column is what to keep to so that a picture comes out the same from both.

CPU Direct2D A stroke's corner A glow at a sharp corner
CaseCPUDirect2DKeep to
Brushes
PointGlow::shape Draws Circle, SoftRectangle and SharpRectangle. Draws every glow as Circle. GlowShape::Circle, the default.
A brush under a rotation, a shear or unequal axes A glow keeps its axes on the screen's and its spread follows the larger axis. A linear gradient's bands stay square to its mapped axis. A radial gradient is carried with the shape. The brush is carried with the shape. A transform that scales both axes alike.
Shapes and transforms
A rectangle, rounded or partial rectangle under a rotation or a shear The upright box between where the transform puts its top-left and bottom-right corners. A rounded rectangle with two radii is a path, and turns. The turned shape. A path for anything that turns.
Widths under unequal axes - strokes, radii, a glow's reach Scaled by the larger of the two axes. Scaled along each axis. A transform that scales both axes alike.
Paths
A stroke's corner Round. Mitered. Corners that are curves, or two strokes with round ends meeting.
An OuterGlow or Shadow layer at a sharp corner Round, from the distance to the outline. Mitered, from one stroke per pixel of reach. castShadow for a shadow; a glow round a curved outline.
A curve or an arc Cut into straight segments, each within half a device pixel of the curve. An arc is made cubics first, within 0.03 percent of its radius. Tessellated by Direct2D, to its own tolerance. An arc is drawn as an arc. Nothing: the CPU's segments never stray half a pixel from the curve, and an arc's cubics a three-thousandth of its radius.
Clips and pixels
A rectangle clip with a fractional edge Every pixel the edge touches is inside. The edge pixel is antialiased. Clips on whole pixels.
drawBitmap, drawPixelView Copies the pixels over what is there, ignores the canvas transform, and is cut by a path clip's bounding box. Composites the pixels over what is there, follows the transform, and is cut by a path clip's shape. An opaque bitmap, under no transform and no path clip.
stagingDraw The view is the surface: it holds what is drawn there, a write lands at once, and a path clip cuts writes to its bounding box. The view is a cleared buffer, composited over the surface when the callback returns, its pixels taken as premultiplied. A path clip cuts it to its shape. Opaque pixels, and nothing read back.
Layers, images and text
An opacity layer, or drawImage, over translucent pixels A layer starts as a copy of what is under it and is mixed back. An image is mixed in, its own alpha taking no part. A layer starts empty and is composited. An image is composited over. Opaque pixels under the layer or the image - the two agree there.
Text Glyph coverage in grayscale. Under a scale, glyphs move with the transform and keep their size. ClearType, grayscale inside an opacity layer. Glyphs scale with the transform. Text drawn under no scale. The antialiasing is the platform's.
Kept images Lost on a resize and on a switch of backend. Also lost when the device goes. hasImage before every drawImage.
The frame
Drawing outside beginPaint and endPaint Draws into the buffer. Draws nothing. Drawing inside a frame.
getNativeRenderTarget, getNativeBrush Null. The device context, and a brush with a reference added for the caller. Platform code only.
castShadow is answered in two ways - built by the canvas as a Shadow layer on the CPU, stated in one piece by Direct2D - and draws the same shadow from both. A uniform scale and a translation, which is what the framework's own transforms are, add nothing to the table: its transform rows are about a rotation, a shear and unequal axes alone.

Do and Don't

Do

  • Hand an outline the bounds of its fill. The stroke lies inside them.
  • Cast a shadow before the shape it belongs to. The shape covers the solid part under it.
  • Fade one shape through its colour's alpha, and a group through ScopedCanvasOpacity.
  • Draw anything that turns as a path. A path is the one shape both backends turn exactly.
  • Ask hasImage before drawing a kept image, and draw it again when the answer is no.
  • Look at the picture on both backends. GPU acceleration in Settings switches while the application runs.

Don't

  • Don't inset an outline by half its stroke. The backend already has.
  • Don't draw a glow of any shape but Circle. Direct2D draws every glow as a circle.
  • Don't draw before a path's first moveTo. What comes before it is undefined behaviour.
  • Don't put a radial gradient's origin on or past its ellipse. The gradient there is undefined behaviour.
  • Don't rely on a stroke's corner where the corner is sharp.
  • Don't read pixels back from a staging view. Under Direct2D it holds nothing that was drawn.
  • Don't draw a bitmap under a transform or a path clip and expect the backends to agree.
  • Don't keep a native brush or render target past the frame. Direct2D's go with the device, and the CPU backend has none.

How It Works

The canvas

The canvas forwards nearly every call as it is. What it keeps is what the backend interface does not report: the clip rectangles, for clipBox, and the transform, so a guard can compose onto it and put it back.

Three calls a backend may decline - fillPartialRoundedRectangle, drawPartialRoundedRectangle and castShadow - by answering false, and the canvas then builds the shape out of a path and draws that. The CPU backend answers the two partial rectangles with painters of its own and declines the shadow, since its glow is already a distance field; Direct2D declines the partial rectangles and answers the shadow. A backend with no view of its own surface to hand a staging callback - Direct2D - is given a cleared buffer by the canvas instead.

The CPU backend

The CPU backend rasterizes into a pixel buffer with no transform stage, so the canvas transform is folded into the geometry where each call arrives: a rect's corners are mapped, and radii and widths are scaled by the transform's scale factor. Rectangles, rounded rectangles and circles each have a painter of their own, written for axis-aligned spans; everything else is a path.

A path is baked into segments first. An arc is turned from SVG's endpoint form into its centre, radii and angles, and cut into cubics of at most a quarter turn. A curve is halved until each piece lies within a tolerance of its chord, and the tolerance is divided by the scale the path is drawn at, the larger axis's under a transform that scales the two apart, so it holds on the screen. A piece halved thirteen times is taken as its chord, flat or not. The tolerance is a CurveQuality, and everything the canvas draws, a path clip included, is baked at Medium: half a device pixel. Low, High and VeryHigh - two pixels, a tenth and a hundredth - are for code that bakes a BakedPixelPath of its own, which nothing in the framework does. Each pixel's coverage comes from its distance to the nearest segment, and inside or outside from the segments its row crosses, which is the even-odd rule. A stroke is the band within half its width of a segment, so its corners come out round; a glow is the same distance taken as a ramp. The coverage is held as a mask, and the brush is composited through it.

A gradient is read from a ramp of 257 colours, built from its stops once per draw. A linear gradient places a pixel on the ramp by how far along its axis the pixel stands. A radial gradient takes the pixel back through the transform into the brush, and solves for the stop position at which the ellipse, shrunk toward the origin, passes through it.

A path clip is such a mask the size of its bounds, and a clip inside another is the two masks multiplied. An opacity layer is a buffer the size of the surface, kept for reuse, that starts as a copy of what is under it - every painter mixes toward its colour by coverage, which is the arithmetic of drawing on something opaque - and is mixed back when the layer closes. A frame that carries alpha clears its dirty rectangle first; any other frame keeps what the last one left, which costs nothing while the children cover every pixel.

Text reaches the backend as glyph coverage, cached once per glyph at a whole pixel origin and composited for a whole run through one mask, the fraction of each pen position resolved by sampling. On Windows the coverage is DirectWrite's, averaged to one value a pixel.

The Direct2D backend

Direct2D carries the canvas transform on its render target, so every primitive and every brush is mapped by the device. An outline is moved in by half its stroke, Direct2D stroking centred on a shape. Strokes take the cap a layer asks for and miter their corners. A path's arc goes into the geometry as an arc, its radii grown first by the same conversion the CPU backend makes, so the two agree on the ellipse wherever the radii given are too small.

Direct2D has no distance field, so a glow is laid down as rings: one stroke of the outline per pixel of reach, the widest first, each given what it has to add to what is already down to leave the falloff standing. A Shadow layer is the rings under a layer that masks out the silhouette, and then the silhouette filled, both aliased so that the two meet on the same pixels.

CPU: the distance to the outline every pixel its own share: one smooth ramp, round past a corner, where the nearest point is the corner Direct2D: one stroke per pixel of reach rings widest first, each given what it adds - six drawn here; mitered at a corner

castShadow skips the rings. A rounded rectangle's distance field is made of pieces a gradient states exactly: beside a straight edge a linear gradient out from it, round a corner a radial one from the arc's centre, and between them the silhouette solid. Each piece is an axis-aligned rectangle drawn aliased, so no pixel is painted twice, and the ramp is stated in sixteen steps of the falloff.

solid linear band radial corner solid the silhouette cut into rectangles linear band beside a straight edge, out to the reach radial corner from the arc's centre: full to the arc, the falloff past it every piece axis-aligned, drawn aliased, none overlapping

Staged pixels are uploaded into one bitmap the size of the surface and drawn back texel for texel, untransformed - they were rasterized at the animated size already. Native brushes are cached for a frame and let go at its end. A lost device is reported by the frame's end; the backend drops what the device owned, builds again on the next frame and asks for a whole repaint.

A switch of backend

The setting is a config node, and its change is what reaches the application's context. The context states the answer once and tells every window. Each form takes it at the top of its next frame: it builds a backend through the factory the application named and hands it to its canvas. Everything the old backend held goes with it - its brushes, its geometries, its kept images - which is what a resize already drops, so nothing above the canvas is told. The whole surface is drawn again, and a window opened after the switch is built on what the application wears at that moment.

Settings: GPU acceleration the check is clicked the GpuAcceleration node the config value changes AppContext::stateBackend states it, tells every window each form, next frame FormBase::stateBackend createBackend the factory the app named Canvas::setBackend old resources gone, all repainted
All manuals Top