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:
- Shapes - rectangles, rounded rectangles with a radius per corner and a choice of sides, ellipses, circles and lines, each drawn as an outline or filled.
- Paths - a
PixelPathof lines and curves, drawn as a stack of layers: fill, stroke, glow and shadow. - Brushes - a solid colour, a linear or a radial gradient, or a point of light.
- State - clips, a transform, opacity layers and kept images, each a push and a pop, with a scoped guard for each.
- Pixels - a bitmap drawn as it is, or a buffer the caller writes into itself.
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
A line and a path have no inside. Their stroke straddles the line or the path it follows, 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.
| Brush | Paints |
|---|---|
SolidColor | One colour. Its alpha is how much of what is underneath shows through. |
LinearGradient | Colour stops along a line from startPoint to
endPoint, the same across it. Before the first stop and past the last the end colour
holds. |
RadialGradient | Colour 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. |
PointGlow | A point of light: its colour at lightPos, thinning
straight to nothing at lightSpread. |
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
});
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.
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.
| Mode | Draws |
|---|---|
PathRenderMode::Fill | The area the figures enclose, even-odd: a figure inside another is a hole. |
PathRenderMode::Stroke | A line of strokeWidth along every figure,
straddling it, its open ends finished by strokeCap. |
PathRenderMode::OuterGlow | A ramp out from the outline to
strokeWidth, as far in as out. What covers the inward half is the shape drawn over
it. |
PathRenderMode::Shadow | The shape's own coverage, solid, and the ramp
outward from it to strokeWidth - what an opaque shape casts. |
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.
// 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);
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();
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.
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.
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.
// 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.
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.
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.
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.
| Call | Draws |
|---|---|
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; };
| Shorthand | Layer |
|---|---|
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
| Call | Adds |
|---|---|
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.
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
| Guard | For 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.
| Case | CPU | Direct2D | Keep 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
hasImagebefore 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.
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.
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.