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

Alpha Blending

Every chapter so far has drawn by overwriting. Later writes destroy earlier ones, which is the painter’s algorithm and is all you need for opaque shapes. Blending is what replaces it when the thing on top is not opaque.

The standard operator is source over destination, one channel at a time:

out = (src * a + dst * (255 - a)) / 255

At a = 255 the source wins outright. At a = 0 nothing happens. In between it is a weighted average, and the weights sum to exactly one, which is why the result never brightens or darkens on its own.

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

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

/// Source-over compositing on one channel:
///
///     out = (src * a + dst * (255 - a)) / 255
///
/// The `+ 127` rounds to nearest instead of truncating. Dividing by 256 with a
/// shift is the tempting shortcut, and it is why so much old blending code
/// cannot reach pure white: at a = 255 it returns 254.
fn blendChannel(src: u8, dst: u8, a: u8) u8 {
    const s = @as(u32, src) * @as(u32, a);
    const d = @as(u32, dst) * (255 - @as(u32, a));
    return @intCast((s + d + 127) / 255);
}

fn blend(src: u32, dst: u32, a: u8) u32 {
    const s = canvas.channels(src);
    const d = canvas.channels(dst);
    return canvas.rgb(
        blendChannel(s.r, d.r, a),
        blendChannel(s.g, d.g, a),
        blendChannel(s.b, d.b, a),
    );
}

/// Read, blend, write back. This is the whole difference from `putPixel`: the
/// destination is now an input, so draw order stops being destructive and
/// starts being meaningful.
fn blendPixel(x: i32, y: i32, color: u32, a: u8) void {
    if (x < 0 or y < 0) return;
    const ux: usize = @intCast(x);
    const uy: usize = @intCast(y);
    if (ux >= canvas.width or uy >= canvas.height) return;

    const i = uy * canvas.width + ux;
    pixels[i] = blend(color, pixels[i], a);
}

fn blendCircle(cx: i32, cy: i32, r: i32, color: u32, a: u8) void {
    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;
            if (dx * dx + dy * dy <= r * r) blendPixel(x, y, color, a);
        }
    }
}

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;

    for (0..canvas.height) |y| {
        for (0..canvas.width) |x| {
            const g: u8 = @intCast((x * 90) / (canvas.width - 1));
            canvas.putPixel(&pixels, @intCast(x), @intCast(y), canvas.rgb(20, g, 60));
        }
    }

    // Three discs at 60% opacity. Where two overlap the background shows
    // through both, and the overlap is brighter than either disc alone.
    blendCircle(24, 13, 11, canvas.rgb(255, 80, 80), 153);
    blendCircle(40, 13, 11, canvas.rgb(80, 255, 120), 153);
    blendCircle(32, 24, 11, canvas.rgb(120, 160, 255), 153);

    try canvas.dump(out, &pixels);

    const white = canvas.rgb(255, 255, 255);
    const black = canvas.rgb(0, 0, 0);

    try out.writeAll("\nwhite over black:\n");
    for ([_]u8{ 0, 64, 128, 192, 255 }) |a| {
        const result = canvas.channels(blend(white, black, a));
        try out.print("  a={d:>3} -> {d:>3} (shift-by-256 would give {d:>3})\n", .{
            a, result.r, (@as(u32, 255) * a) >> 8,
        });
    }

    // Blending is not commutative. Half of red on top of blue is not half of
    // blue on top of red, which is the whole reason draw order still matters.
    const red = canvas.rgb(255, 0, 0);
    const blue = canvas.rgb(0, 0, 255);
    const r_over_b = canvas.channels(blend(red, blue, 64));
    const b_over_r = canvas.channels(blend(blue, red, 64));
    try out.print("\nred over blue  at a=64: {d} {d} {d}\n", .{ r_over_b.r, r_over_b.g, r_over_b.b });
    try out.print("blue over red  at a=64: {d} {d} {d}\n", .{ b_over_r.r, b_over_r.g, b_over_r.b });

    try out.flush();
}

The division you should not skip

Dividing by 256 with a shift is faster than dividing by 255, and it is wrong in a way that shows:

(255 * a) >> 8   // a = 255 gives 254, not 255

The output above prints both columns. An opaque white composite that lands on 254 is why so much older blending code cannot reach pure white, and the error compounds every time you blend onto the same pixel again. (s + d + 127) / 255 rounds to nearest and costs one division the compiler usually turns into a multiply and a shift anyway.

If you want the fast version done right, the usual trick is:

const t = @as(u32, src) * a + 128;
const blended: u8 = @intCast((t + (t >> 8)) >> 8);

That is exact for all 8-bit inputs. Reach for it when a profile says the division matters, not before.

Read, blend, write

The only structural difference from putPixel is that the destination is now an input:

const i = uy * width + ux;
pixels[i] = blend(color, pixels[i], a);

That one change makes draw order meaningful rather than merely destructive. Three discs at 60% opacity overlap in the output, and each overlap is a distinct colour because it is the result of two blends in sequence.

Blending is not commutative

The snippet composites red over blue and blue over red at the same alpha, and gets different answers. This is worth stating plainly because it is the reason back-to-front ordering exists: a renderer that draws translucent geometry in arbitrary order produces arbitrary colours. Sort by depth, draw far to near, or use an order-independent technique.

Where premultiplied alpha comes in

The code here keeps alpha as a separate argument, never stored in the pixel. That is the simple model and it has a known weakness: filtering, scaling, or averaging colours that have their own alpha values gives wrong results at the edges, because a fully transparent pixel still contributes its colour to the average.

Premultiplied alpha stores (r*a, g*a, b*a, a) instead, and the operator loses a multiply:

out = src + dst * (255 - a)

Compositing systems that scale or filter (a GPU, a compositor, a browser) use premultiplied alpha for exactly that reason. It costs a conversion at the boundary, since most image files store straight alpha.

sRGB, the one caveat worth knowing

Everything on this page averages the bytes as stored. Those bytes are not linear light: sRGB applies a transfer curve of roughly x^2.2, so a value of 128 is about 22% of the light of 255, not 50%. Blending in that space makes a 50% composite of black and white come out visibly darker than the eye expects.

Doing it correctly means converting to linear, blending, and converting back, or working in 16-bit linear throughout. Nearly all 8-bit graphics code, including this page, blends in sRGB anyway and accepts the error. It is a deliberate tradeoff rather than an oversight, and it is worth knowing which one you are making.