⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1503+1f1bee62eOn an older Zig?

Circles

A point is inside a circle when its distance to the centre is at most the radius:

sqrt(dx*dx + dy*dy) <= r

Square both sides and the square root disappears:

dx*dx + dy*dy <= r*r

Both sides are now integers, and the test is two multiplies and a compare. This trick is worth internalizing well beyond graphics: any time you are about to call sqrt in order to compare two lengths, don’t. Hit testing, nearest neighbour searches, and collision detection all run on squared distances.

const std = @import("std");
const canvas = @import("_canvas.zig");

var pixels: [canvas.width * canvas.height]u32 = undefined;

/// Scans every pixel in the buffer, which is the obvious version and the wrong
/// one: at r = 7 it tests 2048 pixels to fill about 150.
fn fillCircleNaive(cx: i32, cy: i32, r: i32, color: u32) usize {
    var tested: usize = 0;
    for (0..canvas.height) |y| {
        for (0..canvas.width) |x| {
            tested += 1;
            const dx = @as(i32, @intCast(x)) - cx;
            const dy = @as(i32, @intCast(y)) - cy;
            if (dx * dx + dy * dy <= r * r) {
                canvas.putPixel(&pixels, @intCast(x), @intCast(y), color);
            }
        }
    }
    return tested;
}

/// The same circle, looping only over its bounding box. `putPixel` already
/// clips, so a circle hanging off an edge needs no extra handling here.
fn fillCircle(cx: i32, cy: i32, r: i32, color: u32) usize {
    var tested: usize = 0;
    var y = cy - r;
    while (y <= cy + r) : (y += 1) {
        var x = cx - r;
        while (x <= cx + r) : (x += 1) {
            tested += 1;
            const dx = x - cx;
            const dy = y - cy;
            if (dx * dx + dy * dy <= r * r) {
                canvas.putPixel(&pixels, x, y, color);
            }
        }
    }
    return tested;
}

/// An outline is the same test twice: inside the outer radius and outside the
/// inner one. No new algorithm, just a band.
fn strokeCircle(cx: i32, cy: i32, r: i32, thickness: i32, color: u32) void {
    const outer = r * r;
    const inner = (r - thickness) * (r - thickness);
    var y = cy - r;
    while (y <= cy + r) : (y += 1) {
        var x = cx - r;
        while (x <= cx + r) : (x += 1) {
            const dx = x - cx;
            const dy = y - cy;
            const d2 = dx * dx + dy * dy;
            if (d2 <= outer and d2 > inner) {
                canvas.putPixel(&pixels, x, y, color);
            }
        }
    }
}

pub fn main(init: std.process.Init) !void {
    var buf: [8192]u8 = undefined;
    var file_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &file_writer.interface;

    canvas.fill(&pixels, canvas.rgb(0, 0, 40));

    strokeCircle(20, 16, 13, 2, canvas.rgb(120, 200, 255));
    const naive = fillCircleNaive(20, 16, 7, canvas.rgb(255, 220, 40));
    const boxed = fillCircle(45, 16, 10, canvas.rgb(180, 255, 140));

    // Hangs off three edges at once. Nothing here checks for that; the clip in
    // putPixel does.
    _ = fillCircle(62, 30, 8, canvas.rgb(255, 120, 120));

    try canvas.dump(out, &pixels);

    try out.print("\nfull-buffer scan, r=7:  {d} pixels tested\n", .{naive});
    try out.print("bounding box,     r=10: {d} pixels tested\n", .{boxed});
    try out.print("a circle of r=10 covers about {d} pixels\n", .{
        (314 * 10 * 10) / 100,
    });

    try out.flush();
}

The bounding box

The naive version scans the whole buffer to fill one circle. At 64x32 that is 2048 tests for a shape covering about 150 pixels; at 640x480 a circle of radius 100 costs 307,200 tests to fill 31,000 pixels. Looping y and x over [centre - r, centre + r] instead tests (2r+1)^2 pixels, which the snippet prints alongside the naive count.

The reason this stays a three-line change is that putPixel clips. The bounding box of a circle near an edge runs off the buffer, and nothing in fillCircle checks for that. The last circle in the snippet hangs off three sides at once to show it. Without clipping in one place, every shape routine would need its own @min/@max against the buffer size, and one of them would eventually get it wrong.

An outline is the same test twice

if (d2 <= outer and d2 > inner) putPixel(...);

Inside the outer radius and outside the inner one is a band. No new algorithm, no separate case, and the thickness is a parameter. The midpoint circle algorithm draws a one-pixel outline faster by walking only one octant and mirroring it eight ways, which is the circle equivalent of Bresenham. It is worth reading once. It is also worth knowing that for anything you intend to fill, the squared-distance test is simpler and vectorizes.

What is missing

The edge is hard: a pixel is in or out, with nothing between. That is the staircase you can see in the output, and fixing it means computing what fraction of each pixel falls inside the circle and using that as an alpha value. Antialiasing does exactly that, on this same shape.

Exercises

  1. Ellipses. Scale one axis before squaring: (dx*dx) * ry*ry + (dy*dy) * rx*rx <= rx*rx * ry*ry. Still integers, still no square root.
  2. Rounded rectangles. A rectangle, plus the circle test applied only in the four corner boxes.
  3. A span fill. For each row, solve for the two x values where the circle crosses it and write that run of pixels directly. One @memset per row beats a per-pixel test by a wide margin, and it is the shape a real rasterizer uses.