Learn Zig Series (#166) - Alpha Blending and Compositing
What will I learn?
- What the alpha channel actually represents, and why "coverage" is a better mental model than "transparency";
- The over operator -- the one blend you reach for 95% of the time -- written first the naive way, then the correct way;
- Why compositing in linear light (the whole point of episode 165) is what separates clean edges from muddy fringes;
- Premultiplied alpha: the representation that makes compositing associative, so you can flatten a stack of layers in any order and get the same answer;
- The wider family of Porter-Duff operators (over, in, out, atop, xor) and when each one earns its place;
- Blitting a translucent sprite onto the
Framebufferfrom episode 156, so this is not just theory but a thing you can see; - Testing compositing with identities and associativity, and a fast integer path for the hot loop -- plus how C, Rust and Go write the same blend.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org) -- the code here is written against Zig 0.16;
- The
RgbF,Rgba, and thesrgbToLinear/linearToSrgbpair from episode 165 -- today leans on all of them; - The
Framebuffertype from episode 156, and comfort with floats andcomptime(episode 9); - The ambition to learn Zig programming.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams
- Learn Zig Series (#82) - DNS Resolver from Scratch
- Learn Zig Series (#83) - DNS Server Implementation
- Learn Zig Series (#84) - HTTP/1.1 Deep Dive
- Learn Zig Series (#85) - HTTP/2 Frames and Streams
- Learn Zig Series (#86) - TLS via C Interop
- Learn Zig Series (#87) - WebSocket Protocol
- Learn Zig Series (#88) - WebSocket Server
- Learn Zig Series (#89) - MQTT Messaging Protocol
- Learn Zig Series (#90) - Protocol Buffers Serialization
- Learn Zig Series (#91) - MessagePack Format
- Learn Zig Series (#92) - gRPC Service in Zig
- Learn Zig Series (#93) - SOCKS5 Proxy
- Learn Zig Series (#94) - NAT Traversal and Hole Punching
- Learn Zig Series (#95) - Mini Project: Chat Server - Protocol Design
- Learn Zig Series (#96) - Mini Project: Chat Server - Server Core
- Learn Zig Series (#97) - Mini Project: Chat Server - Client TUI
- Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History
- Learn Zig Series (#99) - Mini Project: DNS-over-HTTPS Proxy
- Learn Zig Series (#100) - Mini Project: Port Scanner
- Learn Zig Series (#101) - Mini Project: HTTP Load Tester - Part 1
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
- Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
- Learn Zig Series (#104) - Mini Project: Reverse Proxy - Load Balancing
- Learn Zig Series (#105) - Mini Project: Reverse Proxy - Health Checks
- Learn Zig Series (#106) - Linked Lists: Singly and Doubly
- Learn Zig Series (#107) - Skip Lists
- Learn Zig Series (#108) - B-Trees
- Learn Zig Series (#109) - Red-Black Trees
- Learn Zig Series (#110) - Tries: Prefix Trees
- Learn Zig Series (#111) - Bloom Filters
- Learn Zig Series (#112) - Cuckoo Filters
- Learn Zig Series (#113) - Ring Buffers: Lock-Free
- Learn Zig Series (#114) - Memory Pools
- Learn Zig Series (#115) - Slab Allocators
- Learn Zig Series (#116) - Sorting Algorithms in Zig
- Learn Zig Series (#117) - Binary Search Variations
- Learn Zig Series (#118) - Graph Representation
- Learn Zig Series (#119) - BFS and DFS
- Learn Zig Series (#120) - Dijkstra and A*
- Learn Zig Series (#121) - Topological Sort
- Learn Zig Series (#122) - Union-Find
- Learn Zig Series (#123) - LRU Cache
- Learn Zig Series (#124) - Consistent Hashing
- Learn Zig Series (#125) - Mini Project: Search Engine - Inverted Index
- Learn Zig Series (#126) - Mini Project: Search Engine - TF-IDF
- Learn Zig Series (#127) - Mini Project: Search Engine - Query Parser
- Learn Zig Series (#128) - Mini Project: Database Engine - Page Storage
- Learn Zig Series (#129) - Mini Project: Database Engine - B-Tree Index
- Learn Zig Series (#130) - Mini Project: Database Engine - SQL Parser
- Learn Zig Series (#131) - Lexing a Simple Language
- Learn Zig Series (#132) - Recursive Descent Parsing
- Learn Zig Series (#133) - AST Design and Traversal
- Learn Zig Series (#134) - Type Checking
- Learn Zig Series (#135) - Bytecode Design
- Learn Zig Series (#136) - Stack-Based Virtual Machine
- Learn Zig Series (#137) - Closures and Upvalues
- Learn Zig Series (#138) - Garbage Collection: Mark and Sweep
- Learn Zig Series (#139) - Garbage Collection: Generational
- Learn Zig Series (#140) - JIT Compilation Basics
- Learn Zig Series (#141) - Regex: Thompson NFA
- Learn Zig Series (#142) - Regex: NFA to DFA
- Learn Zig Series (#143) - Regex: Matching Engine
- Learn Zig Series (#144) - Code Generation: AST to Machine Code
- Learn Zig Series (#145) - Register Allocation
- Learn Zig Series (#146) - Mini Project: Calculator - Lexer/Parser
- Learn Zig Series (#147) - Mini Project: Calculator - Interpreter
- Learn Zig Series (#148) - Mini Project: Calculator - Bytecode Compiler
- Learn Zig Series (#149) - Mini Project: Calculator - VM with Debugger
- Learn Zig Series (#150) - Mini Project: Lisp - Reader
- Learn Zig Series (#151) - Mini Project: Lisp - Evaluator
- Learn Zig Series (#152) - Mini Project: Lisp - Special Forms and Macros
- Learn Zig Series (#153) - Mini Project: Lisp - Standard Library
- Learn Zig Series (#154) - Mini Project: Regex Engine - NFA
- Learn Zig Series (#155) - Mini Project: Regex Engine - Matching
- Learn Zig Series (#156) - Framebuffer Basics
- Learn Zig Series (#157) - Line Drawing: Bresenham
- Learn Zig Series (#158) - Circle and Ellipse Rasterization
- Learn Zig Series (#159) - Polygon Filling: Scanline
- Learn Zig Series (#160) - 2D Transform Matrices
- Learn Zig Series (#161) - Double Buffering and Vsync
- Learn Zig Series (#162) - Sprite Rendering and Tile Maps
- Learn Zig Series (#163) - Bitmap Font Rendering
- Learn Zig Series (#164) - TrueType Parsing
- Learn Zig Series (#165) - Color Spaces: RGB, HSV, sRGB
- Learn Zig Series (#166) - Alpha Blending and Compositing (this post)
Learn Zig Series (#166) - Alpha Blending and Compositing
Last episode was a detour with a promise attached: we learned that a color is three numbers, that those numbers live in a gamma-encoded space called sRGB, and that any honest color math has to happen in linear light -- decode on the way in, encode on the way out. I ended by saying the whole reason for that detour was that the next thing we build stacks one shape on top of another with partial transparency, and that getting the weights right depends entirely on doing the mix in the right space. Well, here we are. Today we put a half-transparent pixel over another one and make it come out correct. This is alpha blending, and the broader operation of stacking layers is compositing -- the machinery behind every UI with a drop shadow, every game with a smoke sprite, every photo editor with a layers panel. Here we go!
But first, the three loose ends from the color-spaces episode.
Solutions to Episode 165 Exercises
Exercise 1 -- add an HSL conversion. HSL is HSV's cousin. The hue is computed identically (same sextants), but the third axis is lightness = (max + min) / 2 in stead of value, and the saturation formula changes because saturation in HSL is measured relative to how close lightness is to the black-or-white extremes. The going-back direction mirrors episode 165's hsvToRgb with a different chroma term:
const std = @import("std");
pub const RgbF = struct { r: f32, g: f32, b: f32 };
pub const Hsl = struct { h: f32, s: f32, l: f32 }; // h in [0,360), s and l in [0,1]
pub fn rgbToHsl(c: RgbF) Hsl {
const max = @max(c.r, @max(c.g, c.b));
const min = @min(c.r, @min(c.g, c.b));
const delta = max - min;
const l = (max + min) / 2.0;
var h: f32 = 0.0;
if (delta > 0.0) {
if (max == c.r) {
h = 60.0 * @mod((c.g - c.b) / delta, 6.0);
} else if (max == c.g) {
h = 60.0 * (((c.b - c.r) / delta) + 2.0);
} else {
h = 60.0 * (((c.r - c.g) / delta) + 4.0);
}
}
if (h < 0.0) h += 360.0;
// Saturation is chroma normalised by how much room lightness leaves before it
// clips into pure black or pure white -- that is the 1 - |2L - 1| denominator.
const s: f32 = if (delta == 0.0) 0.0 else delta / (1.0 - @abs(2.0 * l - 1.0));
return .{ .h = h, .s = s, .l = l };
}
pub fn hslToRgb(c: Hsl) RgbF {
const chroma = (1.0 - @abs(2.0 * c.l - 1.0)) * c.s;
const hp = c.h / 60.0;
const x = chroma * (1.0 - @abs(@mod(hp, 2.0) - 1.0));
const m = c.l - chroma / 2.0;
var r: f32 = 0;
var g: f32 = 0;
var b: f32 = 0;
if (hp < 1.0) {
r = chroma;
g = x;
} else if (hp < 2.0) {
r = x;
g = chroma;
} else if (hp < 3.0) {
g = chroma;
b = x;
} else if (hp < 4.0) {
g = x;
b = chroma;
} else if (hp < 5.0) {
r = x;
b = chroma;
} else {
r = chroma;
b = x;
}
return .{ .r = r + m, .g = g + m, .b = b + m };
}
test "HSL round-trips for a handful of colors" {
const samples = [_]RgbF{
.{ .r = 1, .g = 0, .b = 0 },
.{ .r = 0.2, .g = 0.6, .b = 0.9 },
.{ .r = 0.5, .g = 0.5, .b = 0.5 },
};
for (samples) |c| {
const back = hslToRgb(rgbToHsl(c));
try std.testing.expectApproxEqAbs(c.r, back.r, 0.001);
try std.testing.expectApproxEqAbs(c.g, back.g, 0.001);
try std.testing.expectApproxEqAbs(c.b, back.b, 0.001);
}
}
The m term (the "match lightness" offset) is the only real difference from the HSV code -- everything else is the same sextant logic. HSL shows up all over CSS because "make it lighter" maps to one axis, which designers like. Whether that is better than HSV is a taste question; having both in your toolbox is the point.
Exercise 2 -- a gamma-correct alpha blend. This is the exercise that this entire episode is really about, so it doubles as a warm-up. Composite src over dst at some alpha, but do the mix in linear light: decode both channels with srgbToLinear, interpolate, then re-encode. The identities (alpha = 0 gives dst, alpha = 1 gives src) fall straight out of the arithmetic:
const std = @import("std");
pub const RgbF = struct { r: f32, g: f32, b: f32 };
pub fn srgbToLinear(c: f32) f32 {
if (c <= 0.04045) return c / 12.92;
return std.math.pow(f32, (c + 0.055) / 1.055, 2.4);
}
pub fn linearToSrgb(c: f32) f32 {
if (c <= 0.0031308) return c * 12.92;
return 1.055 * std.math.pow(f32, c, 1.0 / 2.4) - 0.055;
}
// Composite src over dst at coverage `alpha`, mixing in LINEAR light. A per-channel
// helper keeps the decode/encode pair in one place so it cannot drift out of sync.
pub fn blend(src: RgbF, dst: RgbF, alpha: f32) RgbF {
const a = std.math.clamp(alpha, 0.0, 1.0);
const mix = struct {
fn ch(s: f32, d: f32, t: f32) f32 {
const lin = srgbToLinear(s) * t + srgbToLinear(d) * (1.0 - t);
return linearToSrgb(lin);
}
};
return .{
.r = mix.ch(src.r, dst.r, a),
.g = mix.ch(src.g, dst.g, a),
.b = mix.ch(src.b, dst.b, a),
};
}
test "blend obeys its identities and beats the naive mix at the midpoint" {
const red = RgbF{ .r = 1, .g = 0, .b = 0 };
const green = RgbF{ .r = 0, .g = 1, .b = 0 };
// alpha = 0 -> dst untouched, alpha = 1 -> src exactly
const at0 = blend(red, green, 0.0);
try std.testing.expectApproxEqAbs(@as(f32, 1.0), at0.g, 0.0001);
const at1 = blend(red, green, 1.0);
try std.testing.expectApproxEqAbs(@as(f32, 1.0), at1.r, 0.0001);
// at 0.5 the linear blend is brighter than a raw sRGB average
const correct = blend(red, green, 0.5);
const naive_r = (red.r + green.r) * 0.5;
try std.testing.expect(correct.r > naive_r);
}
Notice this blend takes alpha as a separate argument -- the source color and its coverage are handed in apart from each other. That is the straight alpha convention, and in a moment we will see why bundling them together (premultiplied) changes the game.
Exercise 3 -- a perceptual brightness function. The eye does not weigh channels equally: green reads far brighter than blue at the same value. Relative luminance captures that with the fixed weights 0.2126, 0.7152, 0.0722 -- but they only mean anything on linear channels, so you decode first. Threshold the result and you get the "should this label be black or white ink?" decision that every accessible UI needs:
const std = @import("std");
pub const RgbF = struct { r: f32, g: f32, b: f32 };
pub fn srgbToLinear(c: f32) f32 {
if (c <= 0.04045) return c / 12.92;
return std.math.pow(f32, (c + 0.055) / 1.055, 2.4);
}
// Relative luminance per Rec. 709 weights, on LINEAR channels (decode first, always).
pub fn luminance(c: RgbF) f32 {
return 0.2126 * srgbToLinear(c.r) + 0.7152 * srgbToLinear(c.g) + 0.0722 * srgbToLinear(c.b);
}
// Pick readable ink: white on dark backgrounds, black on light ones. 0.18 (middle grey
// in linear light) is a decent threshold -- tune it if your palette lives at the edges.
pub fn readableInk(background: RgbF) RgbF {
return if (luminance(background) < 0.18)
.{ .r = 1, .g = 1, .b = 1 }
else
.{ .r = 0, .g = 0, .b = 0 };
}
test "green outshines blue, and ink flips at the threshold" {
const green = RgbF{ .r = 0, .g = 1, .b = 0 };
const blue = RgbF{ .r = 0, .g = 0, .b = 1 };
try std.testing.expect(luminance(green) > luminance(blue));
// near-black wants white ink, near-white wants black ink
try std.testing.expectEqual(@as(f32, 1.0), readableInk(.{ .r = 0.02, .g = 0.02, .b = 0.02 }).r);
try std.testing.expectEqual(@as(f32, 0.0), readableInk(.{ .r = 0.95, .g = 0.95, .b = 0.95 }).r);
}
That luminance function is worth more than its size suggests -- it is the same math a color picker uses to decide contrast, and it is why a pure-blue button with white text looks fine while a pure-yellow one with white text is unreadable. Right, the color detour is fully closed. Now let us actually stack some pixels.
What the alpha channel really means
Back in episode 156 our pixel was an Rgba -- four u8 bytes, r, g, b, and an a we have quietly ignored ever since. Time to give it a job. The cleanest way to think about alpha is not "transparency" but coverage: an alpha of 1.0 means this pixel fully covers whatever is behind it, 0.0 means it does not exist, and 0.5 means it covers half the area -- imagine a shape whose edge cuts diagonally through the pixel, painting only half of it. That mental model is worth adopting because it explains the blend formula rather than just asserting it. If a source pixel covers a fraction a of the area, then the result is a parts source and (1 - a) parts whatever was underneath. That is the entire idea, and it is called the over operator: "src over dst".
Let me pin down the working type. Episode 165 gave us RgbF for color math; for compositing I want the alpha alongside it, so here is RgbaF, plus the bridges to and from the byte format we actually store:
const std = @import("std");
// Storage format from episode 156. Alpha finally matters.
pub const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
// Working format: straight (non-premultiplied) alpha, each channel a float in [0,1].
pub const RgbaF = struct {
r: f32,
g: f32,
b: f32,
a: f32,
pub fn fromU8(c: Rgba) RgbaF {
return .{
.r = @as(f32, @floatFromInt(c.r)) / 255.0,
.g = @as(f32, @floatFromInt(c.g)) / 255.0,
.b = @as(f32, @floatFromInt(c.b)) / 255.0,
.a = @as(f32, @floatFromInt(c.a)) / 255.0,
};
}
pub fn toU8(self: RgbaF) Rgba {
return .{
.r = quantize(self.r),
.g = quantize(self.g),
.b = quantize(self.b),
.a = quantize(self.a),
};
}
};
fn quantize(x: f32) u8 {
const clamped = std.math.clamp(x, 0.0, 1.0);
return @intFromFloat(@round(clamped * 255.0));
}
Same clamp-and-round discipline as last episode -- out-of-range math must not wrap a byte, and @round rather than a truncating cast keeps 1.0 mapping to 255. Nothing new there, just carrying the alpha along for the ride.
The over operator, done properly
Here is the correct src-over-dst in linear light. When both the source and the destination can be partly transparent (the general case -- think two translucent layers), the result alpha and the result color both follow from the coverage story:
// General src-over-dst compositing in LINEAR light.
// out.a = src.a + dst.a * (1 - src.a)
// out.rgb = (src.rgb*src.a + dst.rgb*dst.a*(1-src.a)) / out.a
// The divide un-weights the color so we store straight (non-premultiplied) alpha again.
pub fn over(src: RgbaF, dst: RgbaF) RgbaF {
const sa = src.a;
const out_a = sa + dst.a * (1.0 - sa);
if (out_a == 0.0) return .{ .r = 0, .g = 0, .b = 0, .a = 0 };
const ch = struct {
fn mix(s: f32, sa_: f32, d: f32, da_: f32, oa: f32) f32 {
const s_lin = srgbToLinear(s) * sa_;
const d_lin = srgbToLinear(d) * da_ * (1.0 - sa_);
return linearToSrgb((s_lin + d_lin) / oa);
}
};
return .{
.r = ch.mix(src.r, sa, dst.r, dst.a, out_a),
.g = ch.mix(src.g, sa, dst.g, dst.a, out_a),
.b = ch.mix(src.b, sa, dst.b, dst.a, out_a),
.a = out_a,
};
}
fn srgbToLinear(c: f32) f32 {
if (c <= 0.04045) return c / 12.92;
return std.math.pow(f32, (c + 0.055) / 1.055, 2.4);
}
fn linearToSrgb(c: f32) f32 {
if (c <= 0.0031308) return c * 12.92;
return 1.055 * std.math.pow(f32, c, 1.0 / 2.4) - 0.055;
}
The common special case -- an opaque background, dst.a == 1.0 -- collapses this to exactly the blend from the exercise: out.a becomes 1, the divide by out_a disappears, and you are left with src*a + dst*(1-a). That is why the overwhelming majority of blends you write reduce to that one line. The full form above only earns its complexity when the thing underneath is itself translucent, which happens the moment you composite layers before flattening them onto an opaque canvas.
Premultiplied alpha: the representation that composes cleanly
There is a subtle problem lurking in "straight" alpha, and it bites at edges. Consider a pixel that is fully transparent -- a = 0. What are its r, g, b? The honest answer is "they do not matter, nothing is there". But image files store some bytes, and a common choice is black (0,0,0,0). Now bilinearly filter that pixel against an opaque red neighbour (which happens whenever you scale or rotate a sprite, episode 160), and the filter averages the color channels ignoring alpha: you get a muddy dark edge, the infamous black fringe around cut-out sprites.
The fix is premultiplied alpha: store the color already multiplied by its own coverage. A half-transparent red is (0.5, 0, 0, 0.5) in stead of (1, 0, 0, 0.5). Now a fully transparent pixel is (0,0,0,0) honestly -- its color contribution really is zero -- and filtering does the right thing for free. Better still, the over operator becomes breathtakingly simple, because the awkward multiplications are already done:
// Premultiplied color: rgb ALREADY carries the alpha weight.
pub const Premul = struct { r: f32, g: f32, b: f32, a: f32 };
pub fn premultiply(c: RgbaF) Premul {
return .{ .r = c.r * c.a, .g = c.g * c.a, .b = c.b * c.a, .a = c.a };
}
pub fn unpremultiply(p: Premul) RgbaF {
if (p.a == 0.0) return .{ .r = 0, .g = 0, .b = 0, .a = 0 };
return .{ .r = p.r / p.a, .g = p.g / p.a, .b = p.b / p.a, .a = p.a };
}
// src over dst, premultiplied: out = src + dst * (1 - src.a). No divide, no special
// cases, every channel (including alpha) uses the exact same formula. THIS is the form
// GPUs, compositors and image libraries actually run.
pub fn overPremul(src: Premul, dst: Premul) Premul {
const inv = 1.0 - src.a;
return .{
.r = src.r + dst.r * inv,
.g = src.g + dst.g * inv,
.b = src.b + dst.b * inv,
.a = src.a + dst.a * inv,
};
}
Look at overPremul -- one formula, applied uniformly to all four channels, no branch, no division. This is not a coincidence: premultiplied alpha is the representation in which compositing is a linear operation, and that linearity is what makes it associative. Compositing layer A over (B over C) gives the same result as (A over B) over C. That property is why a compositor can flatten a stack of dozens of layers in whatever grouping is convenient, cache intermediate results, and never worry that the arithmetic will disagree with itself. Nota bene: the physically-correct pipeline premultiplies in linear light, but a great deal of real code premultiplies in gamma space because it is faster and close enough for UI work -- a tradeoff worth knowing you are making, in stead of stumbling into.
The wider Porter-Duff family
"Over" is the operator you reach for almost always, but Thomas Porter and Tom Duff, in their classic 1984 paper, laid out a whole algebra of compositing operators -- twelve of them -- each defined by two coefficients: how much of the source survives, and how much of the destination survives, expressed in terms of the two alphas. In premultiplied space every one of them is the same shape, out = src * Fs + dst * Fd, and only the two factors Fs and Fd change:
pub const Op = enum { src, dst, over, in, out, atop, xor };
// Each operator is just a choice of two coverage factors applied to the premultiplied
// source and destination. Change the two lines, change the operation -- that is the
// whole Porter-Duff algebra, and it is why one blit routine can serve them all.
fn factors(op: Op, sa: f32, da: f32) struct { fs: f32, fd: f32 } {
return switch (op) {
.src => .{ .fs = 1.0, .fd = 0.0 }, // keep source, drop dest
.dst => .{ .fs = 0.0, .fd = 1.0 }, // keep dest, drop source
.over => .{ .fs = 1.0, .fd = 1.0 - sa }, // source, dest where source is thin
.in => .{ .fs = da, .fd = 0.0 }, // source, but only inside dest's shape
.out => .{ .fs = 1.0 - da, .fd = 0.0 }, // source, but only outside dest
.atop => .{ .fs = da, .fd = 1.0 - sa }, // source inside dest, over the dest
.xor => .{ .fs = 1.0 - da, .fd = 1.0 - sa }, // whichever the other does NOT cover
};
}
pub fn composite(op: Op, src: Premul, dst: Premul) Premul {
const f = factors(op, src.a, dst.a);
return .{
.r = src.r * f.fs + dst.r * f.fd,
.g = src.g * f.fs + dst.g * f.fd,
.b = src.b * f.fs + dst.b * f.fd,
.a = src.a * f.fs + dst.a * f.fd,
};
}
Most of these are niche, but a couple earn their keep regularly. atop is how you paint a texture that must stay within an existing silhouette -- a colored fill clipped to a glyph's shape, for instance. in is a masking operation: keep the source only where the destination already had coverage. And dst is the no-op that is genuinely handy as a default in a table-driven blitter, so an "invisible" layer costs nothing but the dispatch. Notice how over in this table is Fs = 1, Fd = 1 - sa -- plug those into composite and you get overPremul back exactly. One routine, seven behaviours, chosen by a two-line switch. That is the kind of factoring Zig's enum plus switch makes both cheap and exhaustive -- add a new operator and the compiler reminds every switch that has not handled it (episode 6).
Compositing onto the framebuffer
Enough algebra -- let us blit. This is the payoff for episodes 156 through 162: taking a translucent source pixel and stamping it onto the Framebuffer at a coordinate, respecting bounds, so a smoke puff or a UI panel actually lands where you want it. I will keep the framebuffer sketch minimal (we built the real one already) and focus on the blend at each pixel:
const std = @import("std");
pub const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
// Blend a single straight-alpha source pixel over the stored pixel at (x, y).
// Out of bounds is a silent no-op -- clipping a sprite at the screen edge is normal,
// not an error worth propagating up through every draw call.
pub fn blendPixel(self: *Framebuffer, x: usize, y: usize, src: RgbaF) void {
if (x >= self.width or y >= self.height) return;
const idx = y * self.width + x;
const dst = RgbaF.fromU8(self.pixels[idx]);
self.pixels[idx] = over(src, dst).toU8();
}
// Stamp a whole translucent sprite with its top-left at (ox, oy).
pub fn blit(self: *Framebuffer, sprite: []const Rgba, sw: usize, sh: usize, ox: usize, oy: usize) void {
var sy: usize = 0;
while (sy < sh) : (sy += 1) {
var sx: usize = 0;
while (sx < sw) : (sx += 1) {
const src = RgbaF.fromU8(sprite[sy * sw + sx]);
if (src.a == 0.0) continue; // fully transparent: skip the work entirely
self.blendPixel(ox + sx, oy + sy, src);
}
}
}
};
Two small decisions matter. The out-of-bounds check in blendPixel returns silently, because a sprite half off the edge of the screen is the normal case, not an exceptional one -- making it an error would force every caller into try for a thing that is expected to happen constantly. And the src.a == 0.0 skip in blit is the single most effective optimisation in any sprite blitter: real sprites are mostly transparent border, so skipping the fully-empty pixels routinely halves the work before you have optimised anything else. This is exactly the "cheap early-out first, clever SIMD later" ordering episode 34 preached.
Testing compositing
Compositing is a joy to test because it comes with built-in laws. The identities pin down the endpoints, and associativity (in premultiplied space) pins down the whole structure -- if that holds, your operator is almost certainly correct:
const std = @import("std");
test "over identities: transparent source is a no-op, opaque source replaces" {
const bg = RgbaF{ .r = 0.2, .g = 0.4, .b = 0.6, .a = 1.0 };
const fg = RgbaF{ .r = 0.9, .g = 0.1, .b = 0.1, .a = 0.0 };
const r1 = over(fg, bg); // alpha 0 source -> background unchanged
try std.testing.expectApproxEqAbs(bg.r, r1.r, 0.005);
const opaque_fg = RgbaF{ .r = 0.9, .g = 0.1, .b = 0.1, .a = 1.0 };
const r2 = over(opaque_fg, bg); // alpha 1 source -> source wins
try std.testing.expectApproxEqAbs(opaque_fg.r, r2.r, 0.005);
}
test "premultiplied over is associative: A over (B over C) == (A over B) over C" {
const a = Premul{ .r = 0.5, .g = 0.0, .b = 0.0, .a = 0.5 };
const b = Premul{ .r = 0.0, .g = 0.3, .b = 0.0, .a = 0.4 };
const c = Premul{ .r = 0.0, .g = 0.0, .b = 0.7, .a = 1.0 };
const left = overPremul(a, overPremul(b, c));
const right = overPremul(overPremul(a, b), c);
try std.testing.expectApproxEqAbs(left.r, right.r, 0.0001);
try std.testing.expectApproxEqAbs(left.g, right.g, 0.0001);
try std.testing.expectApproxEqAbs(left.b, right.b, 0.0001);
try std.testing.expectApproxEqAbs(left.a, right.a, 0.0001);
}
That associativity test is the one I would keep if I could only keep one. It is a property test in spirit -- it does not check a hand-computed number, it checks a structural truth that must hold for any correct implementation. If someone later "optimises" overPremul and quietly breaks it, this test fails instantly, whereas an identity check on a single pixel might sail right through. Testing invariants rather than examples is a habit that pays off across the whole series, not just here.
Performance: an integer fast path
Floats and two pow calls per channel are fine for correctness and for modest workloads, but a blit loop over a couple million pixels per frame wants something leaner. If you are willing to accept gamma-space blending for a UI (as most compositors do), the classic fast path is fixed-point integer arithmetic straight on the bytes -- no float conversion, no pow, no LUT even. The trick is the (x * 255 + 127) / 255 idiom, which divides by 255 with correct rounding using only integer ops:
// Integer src-over in gamma space, straight alpha, all in u8/u16. Fast and "good enough"
// for UI compositing; NOT physically linear (we skip the sRGB decode on purpose here).
// The (v + 127) / 255 pattern rounds a /255 divide without touching floating point.
pub fn overU8(src: Rgba, dst: Rgba) Rgba {
const sa: u16 = src.a;
const inv: u16 = 255 - sa;
const ch = struct {
fn mix(s: u8, d: u8, a: u16, ia: u16) u8 {
const v: u16 = @as(u16, s) * a + @as(u16, d) * ia;
return @intCast((v + 127) / 255);
}
};
return .{
.r = ch.mix(src.r, dst.r, sa, inv),
.g = ch.mix(src.g, dst.g, sa, inv),
.b = ch.mix(src.b, dst.b, sa, inv),
.a = @intCast((@as(u16, src.a) * 255 + @as(u16, dst.a) * inv + 127) / 255),
};
}
test "integer over matches its endpoints exactly" {
const bg = Rgba{ .r = 10, .g = 20, .b = 30, .a = 255 };
const clear = Rgba{ .r = 200, .g = 0, .b = 0, .a = 0 };
try std.testing.expectEqual(bg.r, overU8(clear, bg).r); // transparent -> unchanged
const solid = Rgba{ .r = 200, .g = 0, .b = 0, .a = 255 };
try std.testing.expectEqual(@as(u8, 200), overU8(solid, bg).r); // opaque -> source
}
This is the honest engineering tradeoff of the episode. overU8 is several times faster than the linear-light float path, and for a window manager compositing UI panels nobody will ever notice it blended in gamma space. For a photo editor or a renderer that has to be correct, you pay for the linear path -- and there you would decode with the comptime 256-entry sRGB table we baked last episode (turning the pow into an array read) and, for the truly hot loops, blend four channels at once with @Vector (episode 19). As always: measure first (episode 34), then reach for the linear-plus-LUT path only where the profiler says the pixels are actually hot.
The same blend in C, Rust and Go
The over operator is a few multiplies and an add, so the arithmetic ports verbatim -- what differs is how each language spells the byte math and the rounding. C does it with plain int promotion and a manual round:
// C: promote to int, round the /255 by hand, cast back. -lm not even needed here.
unsigned char over_u8(unsigned char s, unsigned char d, unsigned char sa) {
int inv = 255 - sa;
return (unsigned char)((s * sa + d * inv + 127) / 255);
}
Rust keeps the same idea but makes the widening explicit with u16, and the as casts are checked at the boundaries you choose:
// Rust: explicit widening to u16 so the multiply cannot overflow, then narrow back.
fn over_u8(s: u8, d: u8, sa: u8) -> u8 {
let inv = 255 - sa as u16;
((s as u16 * sa as u16 + d as u16 * inv + 127) / 255) as u8
}
Go works in uint32 (its image/draw package famously composites 16-bit premultiplied values), so a byte version reads much the same with wider ints:
// Go: uint32 math, same rounding. The standard library's image/draw goes further and
// works in premultiplied 16-bit, which is the premultiplied story from earlier, built in.
func overU8(s, d, sa uint8) uint8 {
inv := 255 - uint32(sa)
return uint8((uint32(s)*uint32(sa) + uint32(d)*inv + 127) / 255)
}
The interesting difference is not the syntax -- it is the standard-library posture. Go's image/draw bakes premultiplied alpha into the core image model, so idiomatic Go composites the "right" way by default. Zig, C and Rust hand you the primitives and let you choose your representation, which is more work but also means no hidden conversions and no surprise about which space you are blending in. For a systems language that is the correct default: explicit over convenient. And Zig's one genuine edge, as last episode, is that the sRGB decode table for the correct path is computed at compile time with no runtime initialiser -- the fast integer path shown here needs no table at all, and the accurate path gets its table for free.
Exercises
Additive and multiply blend modes. Beyond Porter-Duff there are the "blend modes" from image editors. Implement
add(out = min(src + dst, 1), for glows and fire) andmultiply(out = src * dst, for shadows and tinting) as functions onRgbaF. Do them in linear light, and write a test showing thatmultiplywith white leaves the destination unchanged whilemultiplywith black forces it to black.A
dissolveoperator. Not all transparency is smooth.dissolveturns an alpha of0.5into a random 50% of pixels fully opaque and the rest fully transparent -- a stylised, grainy fade. Writedissolve(src, dst, alpha, seed)using a cheap per-pixel hash of the coordinates (episode 22's hashing ideas) so the pattern is stable frame to frame in stead of shimmering. Test thatalpha = 0andalpha = 1still behave like the identities.Flatten a layer stack. Write
flatten(layers: []const Premul) Premulthat composites an ordered slice of premultiplied layers from bottom to top usingoverPremul, returning the single resulting pixel. Then write a test that splits a stack of five layers into two groups, flattens each group, composites the two results, and checks it equals flattening all five at once -- the associativity law, put to work.
What we learned
- Alpha is coverage, not vaguely "transparency" -- an alpha of
ameans the pixel isaparts itself and(1 - a)parts whatever is behind it, and that one sentence is the over operator; - The over operator done right decodes to linear light, blends, and re-encodes; the opaque-background case collapses to the single-line
src*a + dst*(1-a)you will write ninety-five times out of a hundred; - Premultiplied alpha makes compositing a clean, branch-free
src + dst*(1-src.a)that is associative, which is exactly why real compositors and GPUs use it to flatten layer stacks in any order; - The Porter-Duff operators are all one shape --
src*Fs + dst*Fd-- differing only in two coverage factors, so a single blit routine and a smallswitchcover over, in, out, atop and xor; - Compositing tests itself beautifully through identities and associativity -- properties beat hand-picked example numbers for catching a broken "optimisation";
- The integer gamma-space path (
(v + 127) / 255) is the fast, good-enough choice for UI, while the linear path pairs the comptime sRGB table from last episode with@Vectorwhen the profiler says the loop is hot -- and C, Rust and Go do the same arithmetic, differing mostly in whether premultiplied alpha is baked into the standard library.
We can now stack a translucent thing over an opaque thing and get a result that is both visually clean and physically honest -- edges that do not fringe, fades that do not pass through a dead grey middle, and a compositing model that scales from a single blend to a whole stack of layers without the math ever disagreeing with itself. The framebuffer can finally carry pictures that have holes in them and put them on top of each other correctly. Next we start pulling real images in from disk in their compressed forms, which is where the pixels we have been generating meet the pixels the rest of the world ships around. Plenty still to build.
Thanks for reading, and see you in the next one! ;-)