Learn Zig Series (#165) - Color Spaces: RGB, HSV, sRGB

avatar

Learn Zig Series (#165) - Color Spaces: RGB, HSV, sRGB

zig.png

What will I learn?

  • Why a color is three numbers, and what those numbers actually mean -- because "128" is not "half as bright as 255", and that surprise breaks a lot of naive graphics code;
  • The RGB model we have used since episode 156, promoted from 8-bit bytes to normalized floats so color math stops accumulating rounding errors;
  • The HSV model (hue, saturation, value) and the conversions both ways, so picking and rotating colors becomes a one-liner in stead of a headache;
  • The sRGB transfer function: why stored pixels are gamma-encoded, not linear light, and why averaging them directly gives you mud;
  • How to decode to linear light, do honest math, and re-encode -- the exact groundwork the next episode needs;
  • Using comptime (episode 9) to bake a 256-entry sRGB lookup table into the binary at compile time, for free speed;
  • Testing color conversions with round-trips and known values, and how C, Rust and Go write the same gamma curve.

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 and tested against Zig 0.16;
  • The Rgba and Framebuffer types from episode 156, which is where our color story started;
  • Comfort with comptime (episode 9), floats, and the std.math helpers (episode 24);
  • The ambition to learn Zig programming.

Difficulty

  • Advanced

Curriculum (of the Learn Zig Series):

Learn Zig Series (#165) - Color Spaces: RGB, HSV, sRGB

We have been slinging colors around since episode 156 without ever asking what a color number is. A pixel is a Rgba -- four u8 bytes -- and we happily set .{ .r = 255, .g = 128, .b = 0 } for an orange and moved on. That worked because we only ever stored colors and copied them. The moment you want to compute with them -- fade one image into another, brighten a sprite, tint a glyph, blend a half-transparent shape over a background -- the naive answer is wrong, and it is wrong in a way that has bitten every graphics programmer at least once. Before we can mix colors correctly (which is the very next thing we need), we have to understand the three numbers themselves. So today is a detour with a big payoff: RGB, HSV, and the one that trips everyone up, sRGB. Here we go!

But first, three loose ends from the font parser last time.

Solutions to Episode 164 Exercises

Exercise 1 -- advance widths from hhea and hmtx. The hhea table stores numberOfHMetrics at byte 34, and hmtx is then an array of that many 4-byte records (a u16 advance and an i16 left-side bearing). The catch is that glyphs with an id past the last full metric all share that final advance -- a space-saving trick monospace-tailed fonts lean on -- so we clamp the index in stead of running off the end:

const std = @import("std");
pub const ParseError = error{OutOfBounds};
pub const ByteReader = struct {
    data: []const u8,
    pub fn u16At(self: ByteReader, off: usize) ParseError!u16 {
        if (off + 2 > self.data.len) return error.OutOfBounds;
        return std.mem.readInt(u16, self.data[off..][0..2], .big);
    }
};

// hmtx is numberOfHMetrics records of {advanceWidth: u16, leftSideBearing: i16}.
// A glyph id at or beyond numberOfHMetrics reuses the LAST advance (the trailing
// glyphs are monospaced), so we clamp rather than read a record that is not there.
pub fn advanceWidth(hmtx: ByteReader, num_metrics: u16, id: u16) ParseError!u16 {
    if (num_metrics == 0) return error.OutOfBounds;
    const last: u16 = num_metrics - 1;
    const idx: usize = if (id < num_metrics) id else last;
    return hmtx.u16At(idx * 4);
}

test "advance width reads the record, and clamps ids past numberOfHMetrics" {
    // three records: advances 600, 500, 700 (left-side bearings ignored here)
    var buf: [12]u8 = undefined;
    @memset(&buf, 0);
    std.mem.writeInt(u16, buf[0..2], 600, .big);
    std.mem.writeInt(u16, buf[4..6], 500, .big);
    std.mem.writeInt(u16, buf[8..10], 700, .big);
    const r = ByteReader{ .data = &buf };
    try std.testing.expectEqual(@as(u16, 600), try advanceWidth(r, 3, 0));
    try std.testing.expectEqual(@as(u16, 700), try advanceWidth(r, 3, 2));
    try std.testing.expectEqual(@as(u16, 700), try advanceWidth(r, 3, 9)); // clamped to last
}

The clamp is the whole insight: reading hmtx[9] when only three metrics exist is not an error the font is signalling, it is the format telling you "glyph 9 uses metric 2's advance". Guessing wrong here gives you overlapping or spread-out text, which is exactly the kind of bug that looks like a rendering problem but is really a parsing problem.

Exercise 2 -- composite glyph detection. Classify a glyph by the sign of its contour count: an empty byte range is a space, a negative count is a composite (an accented letter assembled from other glyphs), and zero-or-more is a plain simple glyph. A three-way enum makes the caller handle each case explicitly:

const std = @import("std");
pub const ParseError = error{OutOfBounds};
pub const ByteReader = struct {
    data: []const u8,
    pub fn i16At(self: ByteReader, off: usize) ParseError!i16 {
        if (off + 2 > self.data.len) return error.OutOfBounds;
        return std.mem.readInt(i16, self.data[off..][0..2], .big);
    }
};

pub const GlyphKind = enum { empty, simple, composite };

// The first i16 of a glyph header is its contour count. Sign is load-bearing: a
// negative count means "composite" (built from other glyphs), zero-or-more means a
// simple outline. An empty byte range (start == end) is a space -- no header at all.
pub fn glyphKind(glyf: ByteReader, start: u32, end: u32) ParseError!GlyphKind {
    if (start == end) return .empty;
    const count = try glyf.i16At(start);
    if (count < 0) return .composite;
    return .simple;
}

test "glyph kind uses the empty range and the sign of the contour count" {
    var buf: [8]u8 = undefined;
    @memset(&buf, 0);
    std.mem.writeInt(i16, buf[0..2], 2, .big); // +2 contours -> simple
    std.mem.writeInt(i16, buf[2..4], -1, .big); // -1 -> composite
    const r = ByteReader{ .data = &buf };
    try std.testing.expectEqual(GlyphKind.empty, try glyphKind(r, 10, 10));
    try std.testing.expectEqual(GlyphKind.simple, try glyphKind(r, 0, 4));
    try std.testing.expectEqual(GlyphKind.composite, try glyphKind(r, 2, 6));
}

Returning an enum in stead of a bare bool pays off when we finally decode outlines: a switch on GlyphKind gets a compile error the day someone adds a fourth case, and "space" never gets confused with "simple glyph that happens to have zero contours".

Exercise 3 -- a cmap subtable picker. A real font ships several cmap subtables (a legacy Mac one, a Windows Unicode one, sometimes more), and you want the best available. Score each record and keep the winner -- prefer a full Unicode encoding, fall back to the older ones, and error if there is nothing usable:

const std = @import("std");
pub const ParseError = error{ OutOfBounds, NoUsableSubtable };
pub const ByteReader = struct {
    data: []const u8,
    pub fn u16At(self: ByteReader, off: usize) ParseError!u16 {
        if (off + 2 > self.data.len) return error.OutOfBounds;
        return std.mem.readInt(u16, self.data[off..][0..2], .big);
    }
    pub fn u32At(self: ByteReader, off: usize) ParseError!u32 {
        if (off + 4 > self.data.len) return error.OutOfBounds;
        return std.mem.readInt(u32, self.data[off..][0..4], .big);
    }
};

// A cmap opens with version(2) then numTables(2), then numTables records of
// {platformId: u16, encodingId: u16, offset: u32}. Prefer a Unicode table: platform 0
// (Unicode) beats platform 3/enc 10 (Windows full repertoire) beats 3/enc 1 (Windows
// BMP). Everything else scores zero and is only chosen if nothing better exists.
pub fn bestCmapSubtable(cmap: ByteReader, cmap_off: usize) ParseError!u32 {
    const num = try cmap.u16At(cmap_off + 2);
    var best_score: i32 = -1;
    var best_off: u32 = 0;
    var i: usize = 0;
    while (i < num) : (i += 1) {
        const rec = cmap_off + 4 + i * 8;
        const platform = try cmap.u16At(rec);
        const encoding = try cmap.u16At(rec + 2);
        const off = try cmap.u32At(rec + 4);
        const score: i32 = if (platform == 0) 3 else if (platform == 3 and encoding == 10) 2 else if (platform == 3 and encoding == 1) 1 else 0;
        if (score > best_score) {
            best_score = score;
            best_off = off;
        }
    }
    if (best_score <= 0) return error.NoUsableSubtable;
    return best_off;
}

test "the picker prefers a Unicode subtable over a legacy one" {
    var buf: [4 + 16]u8 = undefined;
    @memset(&buf, 0);
    std.mem.writeInt(u16, buf[2..4], 2, .big); // numTables = 2
    std.mem.writeInt(u16, buf[4..6], 1, .big); // rec0 platform 1 (Mac, legacy)
    std.mem.writeInt(u32, buf[8..12], 111, .big); // rec0 offset
    std.mem.writeInt(u16, buf[12..14], 3, .big); // rec1 platform 3 (Windows)
    std.mem.writeInt(u16, buf[14..16], 1, .big); // rec1 encoding 1 (Unicode BMP)
    std.mem.writeInt(u32, buf[16..20], 222, .big); // rec1 offset
    const r = ByteReader{ .data = &buf };
    try std.testing.expectEqual(@as(u32, 222), try bestCmapSubtable(r, 0));
}

The scoring approach scales: when you eventually support a new encoding, you add one branch and bump its number, and the "keep the highest" loop needs no changes at all. Right, the font detour is closed. Now, what is a color?

RGB, but as floats this time

Our Rgba stores each channel as a u8, which is perfect for the framebuffer -- four bytes per pixel, exactly what the hardware and image formats expect. It is a lousy type to compute in, though. Multiply a u8 by 0.5 and you cannot even hold the result; do a series of operations and the rounding at each step piles up. The fix every graphics library reaches for is the same: convert to floating point in the range 0.0 to 1.0, do all your math there, and convert back only when you write to the buffer. Let me define that working type and the two bridges to and from the wire format:

const std = @import("std");

// The storage format from episode 156: four bytes, hardware-friendly, lousy for math.
pub const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };

// The WORKING format: each channel a float in [0, 1]. All color math happens here,
// so a single conversion sits at each end and rounding never accumulates mid-pipeline.
pub const RgbF = struct {
    r: f32,
    g: f32,
    b: f32,

    pub fn fromU8(c: Rgba) RgbF {
        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,
        };
    }

    pub fn toU8(self: RgbF) Rgba {
        return .{ .r = quantize(self.r), .g = quantize(self.g), .b = quantize(self.b) };
    }
};

// Clamp to [0,1] so out-of-gamut math cannot wrap a byte, then round (not truncate) so
// 0.999 becomes 255 and not 254. Rounding vs truncation is a real, visible difference.
fn quantize(x: f32) u8 {
    const clamped = std.math.clamp(x, 0.0, 1.0);
    return @intFromFloat(@round(clamped * 255.0));
}

test "u8 round-trips through the float form without drift" {
    const orange = Rgba{ .r = 255, .g = 128, .b = 0 };
    const back = RgbF.fromU8(orange).toU8();
    try std.testing.expectEqual(orange.r, back.r);
    try std.testing.expectEqual(orange.g, back.g);
    try std.testing.expectEqual(orange.b, back.b);
}

Two small decisions carry weight here. The clamp before quantizing is not optional: color math routinely produces values slightly below 0 or above 1 (a bright highlight, a subtraction that overshoots), and without the clamp @intFromFloat on a value like 1.2 * 255 would be undefined behaviour or a wrapped byte. And @round rather than a bare truncating cast is the difference between 1.0 mapping to 255 and mapping to 254 -- a whole level of brightness lost at the top of the range, which you will notice in a gradient.

HSV: the model humans actually think in

RGB is how the hardware works -- three light emitters -- but it is a miserable way to describe a color. Quick, what is RGB for "the same orange but a bit more muted"? You cannot say without experimenting. HSV reorganises the same colors into three axes a human can reason about: hue (the pure color, an angle from 0 to 360 degrees around a wheel), saturation (how vivid versus grey, 0 to 1), and value (how bright, 0 to 1). "More muted" is just "lower the saturation" -- one number. Here is RGB to HSV:

pub const Hsv = struct { h: f32, s: f32, v: f32 }; // h in [0,360), s and v in [0,1]

// Value is the largest channel; chroma (delta) is the spread between largest and
// smallest. Hue is which channel leads and by how much, measured in 60-degree sextants
// around the wheel. Saturation is chroma relative to value -- how far from grey we are.
pub fn rgbToHsv(c: RgbF) Hsv {
    const max = @max(c.r, @max(c.g, c.b));
    const min = @min(c.r, @min(c.g, c.b));
    const delta = max - min;

    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;

    const s: f32 = if (max == 0.0) 0.0 else delta / max;
    return .{ .h = h, .s = s, .v = max };
}

The three branches are the three "sextants" of the color wheel: red-dominant colors sit around 0 degrees, green around 120, blue around 240. When delta is zero the color is a pure grey and hue is undefined -- we return 0, which is a harmless convention (a grey has no hue, so any value is as good as none). Going the other way reverses the reasoning:

// Reconstruct RGB from the hue sextant. Chroma is the vivid part (v * s); x is the
// second-strongest channel, ramping within each 60-degree slice; m lifts everything so
// value comes out right. Which channels get chroma and x is decided by the sextant.
pub fn hsvToRgb(c: Hsv) RgbF {
    const chroma = c.v * c.s;
    const hp = c.h / 60.0;
    const x = chroma * (1.0 - @abs(@mod(hp, 2.0) - 1.0));
    const m = c.v - chroma;

    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 };
}

Why bother with all this? Because some operations are trivial in HSV and awful in RGB. The classic is a hue rotation -- shifting every color around the wheel, which is how you build a rainbow cycle or a "hue" slider. In HSV it is one addition:

// Rotate a color's hue while keeping its saturation and brightness. Doing this in raw
// RGB means an ugly 3x3 matrix; in HSV it is a single wrapped addition. THIS is why
// the extra model earns its keep -- the operation matches the way the model is shaped.
pub fn rotateHue(c: RgbF, degrees: f32) RgbF {
    var hsv = rgbToHsv(c);
    hsv.h = @mod(hsv.h + degrees, 360.0);
    return hsvToRgb(hsv);
}

test "rotating pure red by 120 degrees lands on pure green" {
    const red = RgbF{ .r = 1, .g = 0, .b = 0 };
    const rotated = rotateHue(red, 120.0);
    try std.testing.expectApproxEqAbs(@as(f32, 0.0), rotated.r, 0.001);
    try std.testing.expectApproxEqAbs(@as(f32, 1.0), rotated.g, 0.001);
    try std.testing.expectApproxEqAbs(@as(f32, 0.0), rotated.b, 0.001);
}

Red at hue 0, shifted 120 degrees, is green at hue 120. Shift another 120 and you land on blue. That the test comes out exactly on green (within floating-point slop) is a nice confirmation that the two conversions are honest inverses of each other.

sRGB: the trap nobody warns you about

Here is the part that surprises people, and it is the reason this whole episode exists. The u8 values in your framebuffer are not proportional to light. A pixel value of 128 is not "half as many photons" as 255 -- it is closer to one fifth. The bytes are deliberately pre-distorted through a curve called the sRGB transfer function (loosely, "gamma"), so that the limited 256 levels spend more of their precision in the dark tones, where human eyes are far more sensitive to small changes. It is a clever perceptual trick, and it is baked into essentially every image file, screen, and framebuffer you will ever touch.

The consequence is brutal for anyone doing color math: you cannot average, add, or blend sRGB values directly and expect physically correct light. To do honest math you must first decode to linear light, do the arithmetic there, then re-encode back to sRGB for storage. The transfer function is a piecewise curve (a tiny linear segment near black, a power curve above it):

const std = @import("std");

// sRGB -> linear light. Stored channels are gamma-ENCODED (perceptually spaced); this
// undoes that so the result is proportional to actual light. A short linear segment
// near black avoids the power curve's infinite slope at zero; 2.4 is the sRGB exponent.
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);
}

// linear light -> sRGB. The exact inverse, for RE-ENCODING after you have done your
// math in linear space. Forget this step and everything comes out washed-out.
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;
}

test "the transfer function round-trips, and 0.5 sRGB is far below 0.5 light" {
    // decode then encode returns the original
    const mid = srgbToLinear(0.5);
    try std.testing.expectApproxEqAbs(@as(f32, 0.5), linearToSrgb(mid), 0.0001);
    // and the punchline: mid-grey sRGB is only ~0.214 of the light, not 0.5
    try std.testing.expect(mid < 0.25);
    // black and white are fixed points
    try std.testing.expectApproxEqAbs(@as(f32, 0.0), srgbToLinear(0.0), 0.0001);
    try std.testing.expectApproxEqAbs(@as(f32, 1.0), srgbToLinear(1.0), 0.0001);
}

That mid < 0.25 assertion is the entire lesson in one line: the byte we casually call "50% grey" carries only about 21% of the actual light. Now watch what that does to a naive blend. Suppose you fade pure red into pure green and stop halfway. The lazy code averages the bytes -- red (255,0,0) and green (0,255,0) give (128,128,0), a dark muddy olive. The correct code decodes both to linear, averages the light, and re-encodes -- and gets a distinctly brighter result, because it added the actual photons rather than the perceptual codes:

// The WRONG way (what most naive code does) versus the RIGHT way. Averaging sRGB bytes
// blends the perceptual codes and darkens; averaging LINEAR light and re-encoding
// blends the actual photons. Same inputs, visibly different -- and only one is correct.
pub fn mixNaive(a: RgbF, b: RgbF) RgbF {
    return .{ .r = (a.r + b.r) * 0.5, .g = (a.g + b.g) * 0.5, .b = (a.b + b.b) * 0.5 };
}

pub fn mixLinear(a: RgbF, b: RgbF) RgbF {
    const mix = struct {
        fn ch(x: f32, y: f32) f32 {
            const lin = (srgbToLinear(x) + srgbToLinear(y)) * 0.5;
            return linearToSrgb(lin);
        }
    };
    return .{ .r = mix.ch(a.r, b.r), .g = mix.ch(a.g, b.g), .b = mix.ch(a.b, b.b) };
}

test "the gamma-correct mix is brighter than the naive byte average" {
    const red = RgbF{ .r = 1, .g = 0, .b = 0 };
    const green = RgbF{ .r = 0, .g = 1, .b = 0 };
    const naive = mixNaive(red, green);
    const correct = mixLinear(red, green);
    // both put ~0.5 in red and green, but the correct one lands HIGHER (brighter)
    try std.testing.expect(correct.r > naive.r);
    try std.testing.expect(correct.g > naive.g);
}

If you have ever wondered why a cross-fade between two photos seems to pass through a dark, dead middle, or why a downscaled image looks dimmer than the original -- this is why. The software averaged gamma-encoded bytes as if they were light. It is one of the most widespread bugs in all of graphics, and now you can see exactly where it hides.

Performance: bake the curve at compile time

srgbToLinear calls std.math.pow, and pow is not cheap -- it is a transcendental function, tens of cycles a call. In a blend loop you might call it a few times per pixel per channel, and a modest 1080p frame is two million pixels. That adds up fast. But look at the input to the decode: it comes from a u8, so there are only 256 possible values. That is a textbook case for a lookup table -- and because we have comptime (episode 9), we can compute that entire table at compile time and bake the 256 floats straight into the binary. Zero runtime setup, zero pow calls in the hot path:

const std = @import("std");

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);
}

// The decode has only 256 possible byte inputs, so precompute ALL of them once, at
// compile time. comptime runs the exact same code the CPU would -- during compilation --
// and the resulting [256]f32 is embedded in the binary. No init, no runtime pow calls.
pub const srgb_to_linear: [256]f32 = blk: {
    @setEvalBranchQuota(200000); // pow is loop-heavy at comptime; raise the budget
    var table: [256]f32 = undefined;
    var i: usize = 0;
    while (i < 256) : (i += 1) {
        table[i] = srgbToLinear(@as(f32, @floatFromInt(i)) / 255.0);
    }
    break :blk table;
};

// The hot-path decode is now a single array read -- a handful of cycles, no branch.
pub fn decode(byte: u8) f32 {
    return srgb_to_linear[byte];
}

test "the comptime table matches the live function at every byte" {
    var i: usize = 0;
    while (i < 256) : (i += 1) {
        const b: u8 = @intCast(i);
        const live = srgbToLinear(@as(f32, @floatFromInt(b)) / 255.0);
        try std.testing.expectApproxEqAbs(live, decode(b), 0.0001);
    }
}

This is the sort of thing that makes Zig quietly satisfying. In C you would either hardcode a 256-line table (generated by a throwaway script you then lose) or fill it at program start-up. In Zig the table is just a const whose value happens to be computed by a loop that runs in the compiler. The @setEvalBranchQuota is the one bit of ceremony: comptime evaluation has a branch budget to stop infinite loops from hanging your build, and 256 iterations of a pow blow past the default, so we raise it. The re-encode direction (linear to sRGB) takes a float, not a byte, so it has no finite domain to tabulate -- there you would use a coarser table plus interpolation, or just eat the pow. Nota bene: measure before you optimise (episode 34) -- the LUT is a clear win here only because the domain is tiny and the call count is huge.

When to reach for which space

A quick field guide, since the three models are tools for different jobs. Keep RGB (as floats) as your working representation for anything that adds or scales light -- blending, lighting, fades. Reach for HSV whenever a human picks or adjusts a color: color pickers, "make it 20% more saturated", hue-cycling animations, generating a palette of evenly-spaced hues. Treat sRGB not as a third creative space but as the encoding your bytes are already in -- the rule is simply "decode on the way in, encode on the way out", and do your real math in linear light in between. There are richer spaces still (HSL, LAB, OKLCH, the last of which is having a well-deserved moment for perceptually-even gradients), but RGB, HSV and the sRGB curve cover the overwhelming majority of what a 2D renderer needs, and they are the foundation the fancier ones build on.

The same gamma curve in C, Rust and Go

The transfer function is pure arithmetic, so it looks nearly identical everywhere -- what differs is the pow call and the number types. C pulls powf from <math.h> and works in float:

// C: powf from <math.h>; you link -lm and there is no compile-time table for free
#include <math.h>
float srgb_to_linear(float c) {
    if (c <= 0.04045f) return c / 12.92f;
    return powf((c + 0.055f) / 1.055f, 2.4f);
}

Rust exposes powf as a method on f32, which reads left-to-right like the math does:

// Rust: powf is a method; a const-fn table needs a build script or once-cell today
fn srgb_to_linear(c: f32) -> f32 {
    if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
}

Go puts Pow in the math package and works in float64, so you convert in and out:

// Go: math.Pow is float64-only, and there is no compile-time evaluation to bake a LUT
import "math"

func srgbToLinear(c float64) float64 {
    if c <= 0.04045 {
        return c / 12.92
    }
    return math.Pow((c+0.055)/1.055, 2.4)
}

The arithmetic is the same in all four. The interesting difference is the one we just used: only Zig lets you fill that 256-entry table during compilation with the very same function, no build script, no lazy-init guard, no separate code-generation step. The C and Go versions need a runtime initialiser or a checked-in generated file; Rust is getting there with const fn but powf is not const yet. It is a small thing, but it is exactly the kind of small thing that adds up across a codebase.

Exercises

  1. Add an HSL conversion. HSL (hue, saturation, lightness) is HSV's cousin, used heavily in CSS. Lightness is (max + min) / 2 and the saturation formula differs from HSV's. Write rgbToHsl and hslToRgb, and add a round-trip test proving hslToRgb(rgbToHsl(c)) returns the original for a handful of colors.

  2. A gamma-correct alpha blend. Write blend(src: RgbF, dst: RgbF, alpha: f32) that composites src over dst in linear light: decode both, mix with lin = src_lin * alpha + dst_lin * (1 - alpha), then re-encode. Test that alpha = 0 returns dst, alpha = 1 returns src, and alpha = 0.5 is brighter than a naive sRGB blend at the same alpha.

  3. A perceptual brightness function. The eye does not weigh the channels equally -- green looks far brighter than blue at the same value. Compute relative luminance as 0.2126*r + 0.7152*g + 0.0722*b on the linear channels (decode first!), and use it to pick a readable text color: return white ink for a dark background and black ink for a light one, thresholding at a luminance of 0.18.

What we learned

  • A color is three numbers, but the numbers only mean something once you know the space -- and "128" is emphatically not "half brightness";
  • RGB as floats in [0, 1] is the right working format for color math, with a single clamp-and-round quantisation at the boundary back to u8;
  • HSV reorganises color into hue, saturation and value, so operations humans think about -- muting, hue rotation, palette generation -- become one-liners in stead of matrix gymnastics;
  • sRGB bytes are gamma-encoded, not linear light, so honest color math means decode to linear, compute, re-encode -- skip it and blends come out dark and dead;
  • comptime lets us bake the 256-entry sRGB decode table straight into the binary, turning a per-pixel pow into a single array read with no runtime setup;
  • The curve is the same arithmetic in C, Rust and Go, but only Zig fills the lookup table during compilation with the very same function.

We now know what our color numbers actually are, and -- crucially -- that mixing them correctly means working in linear light. That last point is not academic: the whole reason we took this detour is that the next thing we build stacks one shape on top of another with partial transparency, and getting the weights right depends entirely on doing that mix in the right space. The tools are all on the bench now: RgbF, the sRGB decode/encode pair, and the reasoning about where the math belongs. Time to put a half-transparent pixel over another one and have it come out right. Plenty still to build.

Bedankt en tot de volgende keer! ;-)

@scipio



0
0
0.000
0 comments