Skip to content

Commit 95b33f0

Browse files
committed
Retune the watchface to the Dark Side of the Moon album cover
Rework the palette and colour handling to match the original album art. - Drop the rainbow's cyan band, taking the spectrum from 7 to 6 to match the album's six-band prism. Retune the palette to the six colours sampled directly from the cover and rename oklch_balanced -> dark_side_of_the_moon, now stored as linear-light colours. - Remove Oklab entirely. The colours round-tripped sRGB -> linear -> Oklab -> linear but were always blended in linear space, so Oklab was a no-op. Delete lib/Oklab.zig, Linear.toOklab and its tests, and the root.zig re-export; Rainbow and Spectrum now use linear colours. - Shrink the default prism size 0.866 -> 0.618034 (golden ratio) and change the default texture from grain to none. - Introduce Srgb.background / Linear.background = the near-black (9,13,12) tone sampled from the cover, and fill the disc background with it instead of pure black. Linear.toSrgb fast-paths this tone and Grain leaves it (and pure black) untouched so the flat background stays clean. - Drop --grain from the README logo command and regenerate logo.png.
1 parent 9139fef commit 95b33f0

12 files changed

Lines changed: 77 additions & 159 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ The texture flags are mutually exclusive; without any, no texture is applied.
4747

4848
```bash
4949
zig build png -Doptimize=ReleaseFast && \
50-
zig-out/bin/png 1024 7 14 logo.png --grain
50+
zig-out/bin/png 1024 7 14 logo.png
5151
```
5252

5353
```bash

lib/Grain.zig

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,12 @@ pub fn apply(self: Self, band: Image.Band(Srgb)) void {
2222
const row = band.buffer[local_y * band.width ..][0..band.width];
2323

2424
for (row, 0..) |*srgb, x| {
25-
// Skip pure black so the dark background stays clean.
26-
if (srgb.r == 0 and srgb.g == 0 and srgb.b == 0) continue;
25+
// Leave the flat background untouched so it stays clean.
26+
const is_black = srgb.r == 0 and srgb.g == 0 and srgb.b == 0;
27+
const is_background = srgb.r == Srgb.background.r and
28+
srgb.g == Srgb.background.g and srgb.b == Srgb.background.b;
29+
30+
if (is_black or is_background) continue;
2731

2832
const offset = noiseAt(x, image_y) * strength;
2933

@@ -100,6 +104,20 @@ test "apply skips black pixels" {
100104
try std.testing.expectEqualSlices(Srgb, &original, &buffer);
101105
}
102106

107+
test "apply skips background pixels" {
108+
const pixel_count = 64 * 64;
109+
110+
var buffer = [_]Srgb{Srgb.background} ** pixel_count;
111+
112+
const original = buffer;
113+
const band = try (Image.init(64, 64)).band(Srgb, &buffer, 64, 0);
114+
const grain = Self{ .normalized_deviation = 0.1 };
115+
116+
grain.apply(band);
117+
118+
try std.testing.expectEqualSlices(Srgb, &original, &buffer);
119+
}
120+
103121
test "multi-band grain matches single-band grain" {
104122
const width = 16;
105123
const height = 48;

lib/Linear.zig

Lines changed: 18 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
const std = @import("std");
22

3-
const Oklab = @import("Oklab.zig");
43
const Srgb = @import("Srgb.zig");
54

65
const Self = @This();
@@ -9,6 +8,13 @@ pub const black: Self = .{ .vec = .{ 0, 0, 0, 1 } };
98
pub const white: Self = .{ .vec = .{ 1, 1, 1, 1 } };
109
pub const transparent: Self = .{ .vec = .{ 0, 0, 0, 0 } };
1110

11+
/// Near-black background tone sampled from the original album cover.
12+
pub const background: Self = blk: {
13+
@setEvalBranchQuota(2000);
14+
15+
break :blk Srgb.background.toLinear();
16+
};
17+
1218
vec: @Vector(4, f32),
1319

1420
pub fn init(r: f32, g: f32, b: f32, a: f32) Self {
@@ -23,32 +29,9 @@ pub fn lerp(a: Self, b: Self, t: f32) Self {
2329
return .{ .vec = a.vec + (b.vec - a.vec) * t_vec };
2430
}
2531

26-
pub fn toOklab(self: Self) Oklab {
27-
const r = self.vec[0];
28-
const g = self.vec[1];
29-
const b = self.vec[2];
30-
31-
// Linear RGB → LMS
32-
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
33-
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
34-
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
35-
36-
// LMS → LMS cube roots
37-
const lp = cubeRoot(l);
38-
const mp = cubeRoot(m);
39-
const sp = cubeRoot(s);
40-
41-
// LMS cube roots → Oklab
42-
return .{ .vec = .{
43-
0.2104542553 * lp + 0.7936177850 * mp - 0.0040720468 * sp,
44-
1.9779984951 * lp - 2.4285922050 * mp + 0.4505937099 * sp,
45-
0.0259040371 * lp + 0.7827717662 * mp - 0.8086757660 * sp,
46-
self.vec[3],
47-
} };
48-
}
49-
5032
pub fn toSrgb(self: Self) Srgb {
51-
// Exact fast path for the common black/transparent background pixels.
33+
// Exact fast paths for the flat background pixels that dominate the frame.
34+
if (@reduce(.And, self.vec == background.vec)) return .background;
5235
if (@reduce(.And, self.vec == black.vec)) return .black;
5336
if (@reduce(.And, self.vec == transparent.vec)) return .transparent;
5437

@@ -152,6 +135,15 @@ test "toSrgb converts black correctly" {
152135
try std.testing.expectEqual(@as(u8, 255), srgb.a);
153136
}
154137

138+
test "toSrgb fast-paths the background tone" {
139+
const srgb = background.toSrgb();
140+
141+
try std.testing.expectEqual(Srgb.background.r, srgb.r);
142+
try std.testing.expectEqual(Srgb.background.g, srgb.g);
143+
try std.testing.expectEqual(Srgb.background.b, srgb.b);
144+
try std.testing.expectEqual(@as(u8, 255), srgb.a);
145+
}
146+
155147
test "toSrgb converts white correctly" {
156148
const srgb = white.toSrgb();
157149

@@ -216,21 +208,3 @@ test "cubeRoot computes cube root correctly" {
216208
try std.testing.expectApproxEqAbs(@as(f32, 0.5), cubeRoot(0.125), 1e-5);
217209
try std.testing.expectEqual(@as(f32, 0.0), cubeRoot(0.0));
218210
}
219-
220-
test "toOklab produces expected L for white" {
221-
const oklab = white.toOklab();
222-
223-
try std.testing.expectApproxEqAbs(@as(f32, 1.0), oklab.vec[0], 1e-4);
224-
try std.testing.expectApproxEqAbs(@as(f32, 0.0), oklab.vec[1], 1e-4);
225-
try std.testing.expectApproxEqAbs(@as(f32, 0.0), oklab.vec[2], 1e-4);
226-
}
227-
228-
test "round-trip Linear → Oklab → Linear preserves values" {
229-
const original = Self.init(0.4, 0.6, 0.2, 1.0);
230-
const back = original.toOklab().toLinear();
231-
232-
try std.testing.expectApproxEqAbs(original.vec[0], back.vec[0], 1e-5);
233-
try std.testing.expectApproxEqAbs(original.vec[1], back.vec[1], 1e-5);
234-
try std.testing.expectApproxEqAbs(original.vec[2], back.vec[2], 1e-5);
235-
try std.testing.expectApproxEqAbs(original.vec[3], back.vec[3], 1e-5);
236-
}

lib/Oklab.zig

Lines changed: 0 additions & 41 deletions
This file was deleted.

lib/Rainbow.zig

Lines changed: 26 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
const std = @import("std");
22

33
const Linear = @import("Linear.zig");
4-
const Oklab = @import("Oklab.zig");
54
const Srgb = @import("Srgb.zig");
65

76
const Self = @This();
@@ -11,89 +10,57 @@ pub const ColorId = enum {
1110
orange,
1211
yellow,
1312
green,
14-
cyan,
1513
blue,
1614
violet,
1715
};
1816

1917
pub const color_count: usize = @typeInfo(ColorId).@"enum".fields.len;
2018

21-
oklab_colors: [color_count]Oklab,
19+
colors: [color_count]Linear,
2220

2321
fn init(srgb_colors: [color_count]Srgb) Self {
2422
@setEvalBranchQuota(10000);
2523

26-
var oklab_colors: [color_count]Oklab = undefined;
24+
var colors: [color_count]Linear = undefined;
2725

28-
for (srgb_colors, 0..) |srgb, i| {
29-
oklab_colors[i] = srgb.toLinear().toOklab();
26+
for (srgb_colors, &colors) |srgb, *color| {
27+
color.* = srgb.toLinear();
3028
}
3129

32-
return .{ .oklab_colors = oklab_colors };
30+
return .{ .colors = colors };
3331
}
3432

35-
pub const oklch_balanced: Self = init(.{
36-
.{ .r = 255, .g = 64, .b = 64 },
37-
.{ .r = 255, .g = 160, .b = 0 },
38-
.{ .r = 220, .g = 220, .b = 0 },
39-
.{ .r = 0, .g = 200, .b = 80 },
40-
.{ .r = 0, .g = 180, .b = 220 },
41-
.{ .r = 80, .g = 100, .b = 255 },
42-
.{ .r = 180, .g = 80, .b = 255 },
33+
/// The six band colours sampled from the original album cover, in spectral order.
34+
pub const dark_side_of_the_moon: Self = init(.{
35+
.{ .r = 210, .g = 36, .b = 46 },
36+
.{ .r = 224, .g = 122, .b = 38 },
37+
.{ .r = 249, .g = 221, .b = 0 },
38+
.{ .r = 87, .g = 162, .b = 67 },
39+
.{ .r = 0, .g = 154, .b = 204 },
40+
.{ .r = 110, .g = 95, .b = 150 },
4341
});
4442

4543
pub fn reversed(self: Self) Self {
46-
var oklab_colors = self.oklab_colors;
44+
var colors = self.colors;
4745

48-
std.mem.reverse(Oklab, &oklab_colors);
46+
std.mem.reverse(Linear, &colors);
4947

50-
return .{ .oklab_colors = oklab_colors };
48+
return .{ .colors = colors };
5149
}
5250

53-
/// The solid band colours in palette order, converted to linear light. Each equal
54-
/// 1/N slice of [0, 1] is filled with one of these, blending between neighbours only
55-
/// across the one-pixel seam at each band boundary.
56-
pub fn bandColors(self: Self) [color_count]Linear {
57-
var colors: [color_count]Linear = undefined;
58-
59-
for (self.oklab_colors, &colors) |oklab, *color| {
60-
color.* = oklab.toLinear();
61-
}
51+
test "init converts sRGB band colours to linear light" {
52+
const expected = (Srgb{ .r = 210, .g = 36, .b = 46 }).toLinear();
6253

63-
return colors;
54+
try std.testing.expectEqual(expected.vec, dark_side_of_the_moon.colors[0].vec);
6455
}
6556

66-
test "init converts sRGB through to Oklab" {
67-
// The first palette colour must round-trip its source sRGB through Oklab back to
68-
// linear light.
69-
const expected = (Srgb{ .r = 255, .g = 64, .b = 64 }).toLinear();
70-
const actual = oklch_balanced.oklab_colors[0].toLinear();
71-
72-
inline for (0..4) |i| {
73-
try std.testing.expectApproxEqAbs(expected.vec[i], actual.vec[i], 1e-4);
74-
}
75-
}
57+
test "reversed mirrors the palette order" {
58+
const mirrored = dark_side_of_the_moon.reversed();
7659

77-
test "init sets alpha to 1" {
78-
for (oklch_balanced.oklab_colors) |c| {
79-
try std.testing.expectApproxEqAbs(@as(f32, 1.0), c.vec[3], 1e-6);
60+
for (0..color_count) |i| {
61+
try std.testing.expectEqual(
62+
dark_side_of_the_moon.colors[i].vec,
63+
mirrored.colors[color_count - 1 - i].vec,
64+
);
8065
}
8166
}
82-
83-
test "reversed swaps first and last colors" {
84-
const rainbow = oklch_balanced;
85-
const reversed_rainbow = rainbow.reversed();
86-
87-
try std.testing.expectEqual(rainbow.oklab_colors[0].vec, reversed_rainbow.oklab_colors[6].vec);
88-
try std.testing.expectEqual(rainbow.oklab_colors[6].vec, reversed_rainbow.oklab_colors[0].vec);
89-
try std.testing.expectEqual(rainbow.oklab_colors[3].vec, reversed_rainbow.oklab_colors[3].vec);
90-
}
91-
92-
test "bandColors returns each palette colour in linear light" {
93-
const colors = oklch_balanced.bandColors();
94-
95-
try std.testing.expectEqual(color_count, colors.len);
96-
try std.testing.expectEqual(oklch_balanced.oklab_colors[0].toLinear().vec, colors[0].vec);
97-
try std.testing.expectEqual(oklch_balanced.oklab_colors[3].toLinear().vec, colors[3].vec);
98-
try std.testing.expectEqual(oklch_balanced.oklab_colors[6].toLinear().vec, colors[6].vec);
99-
}

lib/Spectrum.zig

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,7 @@ pub fn render(
6969

7070
if (dot_exact > 0.9999 or dot_exact < -0.9999) return;
7171

72-
// Solid band colours in palette order, converted once per frame; the per-pixel
73-
// path only indexes and blends this small array.
74-
const band_colors = rainbow.bandColors();
72+
const band_colors = rainbow.colors;
7573

7674
const band_height = band.bandHeight();
7775
const y_offset: f32 = @floatFromInt(band.y_offset);
@@ -263,7 +261,7 @@ fn antialiasedBand(
263261
}
264262

265263
test "render produces spectrum with rotated viewport" {
266-
const rainbow = Rainbow.oklch_balanced;
264+
const rainbow = Rainbow.dark_side_of_the_moon;
267265
const image = Image.init(48, 64);
268266
const viewport = image.viewportRotated(.clockwise_90);
269267
const pixel_count = 48 * 64;
@@ -288,7 +286,7 @@ test "render produces spectrum with rotated viewport" {
288286
}
289287

290288
test "attenuation reduces brightness near origin" {
291-
const rainbow = Rainbow.oklch_balanced;
289+
const rainbow = Rainbow.dark_side_of_the_moon;
292290
const size = 200;
293291
const image = Image.init(size, size);
294292
const viewport = image.viewport();
@@ -399,8 +397,8 @@ fn spectrumPositionAt(spectrum: Self, point: @Vector(2, f32)) struct {
399397
}
400398

401399
test "antialiasedBand blends across a seam but stays solid within a band" {
402-
const rainbow = Rainbow.oklch_balanced;
403-
const band_colors = rainbow.bandColors();
400+
const rainbow = Rainbow.dark_side_of_the_moon;
401+
const band_colors = rainbow.colors;
404402
const scale: f32 = 100.0;
405403
const count: f32 = @floatFromInt(Rainbow.color_count);
406404

lib/Srgb.zig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ pub const black: Self = .{ .r = 0, .g = 0, .b = 0 };
88
pub const white: Self = .{ .r = 255, .g = 255, .b = 255 };
99
pub const transparent: Self = .{ .r = 0, .g = 0, .b = 0, .a = 0 };
1010

11+
/// Near-black background tone sampled from the original album cover.
12+
pub const background: Self = .{ .r = 9, .g = 13, .b = 12 };
13+
1114
r: u8,
1215
g: u8,
1316
b: u8,

lib/Watchface.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const prism_tint = Linear.init(0.03, 0.34, 0.52, 1.0);
2222

2323
pub fn render(self: Self, band: Image.Band(Linear), viewport: anytype, clock: Clock) void {
2424
const right_side = clock.hour_hand.get(.green).end[0] > 0;
25-
const rainbow = if (right_side) Rainbow.oklch_balanced.reversed() else Rainbow.oklch_balanced;
25+
const rainbow = if (right_side) Rainbow.dark_side_of_the_moon.reversed() else Rainbow.dark_side_of_the_moon;
2626

2727
const hand_glow = Glow{
2828
.normalized_width = self.hand_glow_normalized_width,

lib/config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"background_enabled": true,
3-
"prism_normalized_size": 0.866,
3+
"prism_normalized_size": 0.618034,
44
"prism_glow_normalized_width": 0.07,
55
"rainbow_normalized_spread": 0.5,
66
"hand_glow_normalized_width": 0.005,
7-
"texture": "grain",
7+
"texture": "none",
88
"grain_normalized_deviation": 0.07
99
}

lib/frame.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ pub fn renderBand(
6060

6161
@memset(
6262
linear_buffer,
63-
if (config.background_enabled) Linear.black else Linear.transparent,
63+
if (config.background_enabled) Linear.background else Linear.transparent,
6464
);
6565

6666
const watchface = Watchface{

0 commit comments

Comments
 (0)