Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions examples/gpu-components/src/tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,7 @@ test "gpu components display list renders stable reference snapshot" {
// on checked/filled states. Update deliberately when component
// rendering changes, reviewing the rendered pixels (reference render
// dump or docs previews — same emitters) first.
try std.testing.expectEqual(@as(u64, 5753353894120093539), referenceSurfaceSignature(pixels));
try std.testing.expectEqual(@as(u64, 4649016855701722499), referenceSurfaceSignature(pixels));
try expectVisiblePixel(surface.pixelRgba8(36, 36));
try expectVisiblePixel(surface.pixelRgba8(92, 88));
try expectVisiblePixel(surface.pixelRgba8(330, 160));
Expand Down Expand Up @@ -693,7 +693,7 @@ test "gpu components display list renders stable geist reference snapshot" {
const scratch = try std.testing.allocator.alloc(u8, pixel_count);
defer std.testing.allocator.free(scratch);
const surface = try renderComponentsReferenceSurface(componentTokensForPack(.geist, .light), pixels, scratch);
try std.testing.expectEqual(@as(u64, 8766972054033897563), referenceSurfaceSignature(pixels));
try std.testing.expectEqual(@as(u64, 937194073311940051), referenceSurfaceSignature(pixels));
try expectVisiblePixel(surface.pixelRgba8(36, 36));
try expectVisiblePixel(surface.pixelRgba8(92, 88));
try expectVisiblePixel(surface.pixelRgba8(330, 160));
Expand Down Expand Up @@ -785,12 +785,12 @@ test "geist button group renders the detached secondary-tab register in both sch
// one step short of the pack's pure-black light primary — probed at
// the chip's lower body, clear of the knockout label.
try std.testing.expectEqual([4]u8{ 23, 23, 23, 255 }, light.pixelRgba8(30, 48));
try std.testing.expectEqual(@as(u64, 13158221911267186466), referenceSurfaceSignature(pixels));
try std.testing.expectEqual(@as(u64, 7474062486595489698), referenceSurfaceSignature(pixels));

const dark = try renderButtonGroupReferenceSurface(componentTokensForPack(.geist, .dark), pixels, scratch);
// Dark inverts to porcelain #ededed.
try std.testing.expectEqual([4]u8{ 237, 237, 237, 255 }, dark.pixelRgba8(30, 48));
try std.testing.expectEqual(@as(u64, 9922762454948493824), referenceSurfaceSignature(pixels));
try std.testing.expectEqual(@as(u64, 10632788448036391376), referenceSurfaceSignature(pixels));
}

test "house button group keeps the attached segmented bar through the shared specimen" {
Expand All @@ -804,7 +804,7 @@ test "house button group keeps the attached segmented bar through the shared spe
const scratch = try std.testing.allocator.alloc(u8, button_group_surface_pixels);
defer std.testing.allocator.free(scratch);
_ = try renderButtonGroupReferenceSurface(componentTokens(), pixels, scratch);
try std.testing.expectEqual(@as(u64, 16337717838073850632), referenceSurfaceSignature(pixels));
try std.testing.expectEqual(@as(u64, 2150490712731232712), referenceSurfaceSignature(pixels));
}

/// Render a two-trigger tab strip (one active) on a small reference
Expand Down Expand Up @@ -922,7 +922,7 @@ test "house tabs keep the flush pill strip through the shared specimen" {
const scratch = try std.testing.allocator.alloc(u8, tabs_surface_pixels);
defer std.testing.allocator.free(scratch);
_ = try renderTabsReferenceSurface(componentTokens(), pixels, scratch);
try std.testing.expectEqual(@as(u64, 15995515449431694753), referenceSurfaceSignature(pixels));
try std.testing.expectEqual(@as(u64, 16693469301949121985), referenceSurfaceSignature(pixels));
}

test "gpu components house reference snapshot is reproducible through the shared per-theme path" {
Expand All @@ -935,7 +935,7 @@ test "gpu components house reference snapshot is reproducible through the shared
const scratch = try std.testing.allocator.alloc(u8, pixel_count);
defer std.testing.allocator.free(scratch);
_ = try renderComponentsReferenceSurface(componentTokens(), pixels, scratch);
try std.testing.expectEqual(@as(u64, 5753353894120093539), referenceSurfaceSignature(pixels));
try std.testing.expectEqual(@as(u64, 4649016855701722499), referenceSurfaceSignature(pixels));
}

test "gpu components catalog previews use canonical built-in foundations" {
Expand Down
2 changes: 1 addition & 1 deletion examples/gpu-dashboard/src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const expected_dashboard_interaction_command_count: usize = 70;
// deliberately when rendering changes, reviewing the rendered pixels
// (reference captures or the docs previews — same emitters) first; the
// spot pixels below still guard basic visibility.
const expected_dashboard_reference_signature: u64 = 14864234856790650620;
const expected_dashboard_reference_signature: u64 = 7222753033888946849;
const expected_dashboard_widget_node_count: usize = 48;
const expected_dashboard_snapshot_widget_count: usize = 48;
const refresh_command = "dashboard.refresh";
Expand Down
4 changes: 2 additions & 2 deletions src/primitives/canvas/chart_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -722,8 +722,8 @@ test "chart golden: line + bar + band render byte-identically in light and dark"
// category labels (q1, q3) under the bars. Both themes clear with their
// background token. Update deliberately when chart rendering changes,
// reviewing the dumped pixels first.
const golden_light_signature: u64 = 11237334215963301158;
const golden_dark_signature: u64 = 4053146140543071333;
const golden_light_signature: u64 = 4743613392161459263;
const golden_dark_signature: u64 = 13208780493823093371;

fn goldenDumpRequested() bool {
if (comptime !@import("builtin").link_libc) return false;
Expand Down
2 changes: 1 addition & 1 deletion src/primitives/canvas/markdown_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ test "the README-shaped fixture renders through the mapper and the reference ren
// separators, fenced-code panels, and near-black underlined links.
// Update deliberately when markdown rendering changes, reviewing the
// rendered pixels first (see reference_tests.zig conventions).
const markdown_document_reference_signature: u64 = 6015448079037287912;
const markdown_document_reference_signature: u64 = 4124710367581899536;


test "bare URLs autolink at word boundaries with trailing punctuation trimmed" {
Expand Down
141 changes: 135 additions & 6 deletions src/primitives/canvas/reference.zig
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,11 @@ pub const ReferenceRenderSurface = struct {
}

pub fn renderPass(self: ReferenceRenderSurface, pass: CanvasRenderPass, clear_color: Color) Error!void {
// One-time sRGB decode table fill (see the table's doc comment);
// outside the per-pixel loops so the hot path pays no checks.
// One-time sRGB decode/encode table fills (see the tables' doc
// comments); outside the per-pixel loops so the hot path pays no
// checks.
ensureSrgbToLinearByteTable();
ensureLinearToSrgbTable();
// Fresh per-pass panel-fill budget (see the memo's doc comment).
if (self.render_memo) |memo| memo.image_scale_fills_this_pass = 0;
const scale = referencePassScale(pass.scale);
Expand Down Expand Up @@ -309,7 +311,7 @@ pub const ReferenceRenderSurface = struct {
continue;
}
const coverage = referenceRoundedRectCoverage(point, rect, radius);
if (coverage > 0) self.blendPixel(@intCast(x), @intCast(y), referenceScaleColorAlpha(referenceSampleFill(value.fill, command.transform, point), coverage), command.opacity);
if (coverage > 0) self.blendPixelCoverage(@intCast(x), @intCast(y), referenceSampleFill(value.fill, command.transform, point), coverage, command.opacity, .linear_light);
}
}
self.memoStore(probe, pixel_rect);
Expand Down Expand Up @@ -353,7 +355,7 @@ pub const ReferenceRenderSurface = struct {
}
const coverage = std.math.clamp(referenceRoundedRectCoverage(point, outer, outer_radius) - referenceRoundedRectCoverage(point, inner, inner_radius), 0, 1);
if (coverage > 0) {
self.blendPixel(@intCast(x), @intCast(y), referenceScaleColorAlpha(referenceSampleFill(value.stroke.fill, command.transform, point), coverage), command.opacity);
self.blendPixelCoverage(@intCast(x), @intCast(y), referenceSampleFill(value.stroke.fill, command.transform, point), coverage, command.opacity, .linear_light);
}
}
}
Expand Down Expand Up @@ -389,6 +391,7 @@ pub const ReferenceRenderSurface = struct {
.fill = value.fill,
.transform = command.transform,
.opacity = command.opacity,
.coverage_blend = .linear_light,
};
vector.fillPath(
value.elements,
Expand Down Expand Up @@ -416,6 +419,7 @@ pub const ReferenceRenderSurface = struct {
.fill = value.stroke.fill,
.transform = command.transform,
.opacity = command.opacity,
.coverage_blend = .linear_light,
};
vector.strokePath(
value.elements,
Expand Down Expand Up @@ -823,11 +827,16 @@ pub const ReferenceRenderSurface = struct {
if (builder.slice().len == 0) return true; // Space: nothing to ink.

const pixel_rect = referencePixelRect(draw_bounds, self.width, self.height) orelse return true;
// Glyph coverage blends in sRGB, not linear light (see
// `CoverageBlend`): apparent text weight is set by how edge
// pixels darken, and re-blending them in linear light thins
// dark-on-light runs and blooms light-on-dark runs at UI sizes.
var sink = ReferenceCoverageSink{
.surface = self,
.fill = .{ .color = value.color },
.transform = command.transform,
.opacity = command.opacity,
.coverage_blend = .srgb,
};
// The outline is already in device space; TrueType interiorness
// is the nonzero rule.
Expand Down Expand Up @@ -869,18 +878,66 @@ pub const ReferenceRenderSurface = struct {
self.pixels[index + 3] = out[3];
}

/// Blend one pixel whose fractional alpha is ANTI-ALIASED EDGE
/// COVERAGE (kept separate from the color's own alpha so the blend
/// can tell an AA fringe from a translucent wash — see
/// `CoverageBlend`).
fn blendPixelCoverage(self: ReferenceRenderSurface, x: usize, y: usize, color: Color, coverage: f32, opacity: f32, blend: CoverageBlend) void {
const index = (y * self.width + x) * 4;
const dst = [4]u8{
self.pixels[index + 0],
self.pixels[index + 1],
self.pixels[index + 2],
self.pixels[index + 3],
};
const out = blendRgba8Coverage(dst, color, coverage, opacity, blend);
self.pixels[index + 0] = out[0];
self.pixels[index + 1] = out[1];
self.pixels[index + 2] = out[2];
self.pixels[index + 3] = out[3];
}

fn findImage(self: ReferenceRenderSurface, id: ImageId) ?ReferenceImage {
return findReferenceImage(self.images, id);
}
};

/// Which space a shape's fractional edge coverage blends in.
///
/// GEOMETRY — rounded rects, filled/stroked paths, icons, chart marks —
/// blends its anti-aliased edge pixels in LINEAR LIGHT. Compositing the
/// sRGB-encoded bytes directly weights half coverage far below half the
/// light (a 50% black-on-white fringe lands near 21% luminance instead
/// of 50%), so every edge grows a dark rim on light backgrounds (a light
/// halo on dark ones) that reads as jagged even though the coverage
/// values are correct. Decoding to linear light, blending, and
/// re-encoding removes the rim.
///
/// GLYPHS stay in sRGB. Text coverage funnels through the exact same
/// vector core, but apparent text WEIGHT is a product of how edge pixels
/// darken: the same coverage blended in linear light renders visibly
/// thinner dark-on-light runs and bloomier light-on-dark runs at UI
/// sizes, and the toolkit's type ramp was tuned against sRGB-blended
/// stems. sRGB glyph compositing also matches the packet-backed macOS
/// text pipeline, so mixed CPU/host frames keep one text weight.
///
/// Only opaque-source fractional-coverage pixels differ between the two
/// modes: fully covered pixels short-circuit identically in either
/// space, and translucent sources (washes, scrims, faded layers) keep
/// sRGB blending so overlay brightness — tuned in sRGB terms — is
/// untouched and an AA edge never diverges from the interior it borders.
const CoverageBlend = enum { linear_light, srgb };

/// Per-pixel coverage sink for the vector core: samples the fill at the
/// pixel center and blends with the coverage folded into alpha.
/// pixel center and blends with the coverage, in the blend space the
/// emitter declared (geometry linear-light, glyphs sRGB — see
/// `CoverageBlend`).
const ReferenceCoverageSink = struct {
surface: ReferenceRenderSurface,
fill: Fill,
transform: Affine,
opacity: f32,
coverage_blend: CoverageBlend,

pub fn pixel(self: *ReferenceCoverageSink, x: i32, y: i32, coverage: f32) void {
if (x < 0 or y < 0) return;
Expand All @@ -889,7 +946,7 @@ const ReferenceCoverageSink = struct {
if (px >= self.surface.width or py >= self.surface.height) return;
const point = referencePixelCenter(px, py);
const color = referenceSampleFill(self.fill, self.transform, point);
self.surface.blendPixel(px, py, referenceScaleColorAlpha(color, coverage), self.opacity);
self.surface.blendPixelCoverage(px, py, color, coverage, self.opacity, self.coverage_blend);
}
};

Expand Down Expand Up @@ -1339,6 +1396,31 @@ fn ensureSrgbToLinearByteTable() void {
srgb_to_linear_byte_table_ready = true;
}

/// Precomputed `referenceLinearToSrgb` over evenly spaced linear inputs:
/// the linear-light coverage blend re-encodes three channels per fringe
/// pixel, and each direct encode costs a `pow`. 4096 entries keep the
/// nearest-entry result within one 8-bit step of direct evaluation: the
/// curve is steepest near black (slope 12.92), where one table cell
/// still spans under one output byte step, so the looked-up value sits
/// within half a step of exact and the final byte rounding moves by at
/// most one level. Same benign-race lazy fill as the decode table above.
const linear_to_srgb_table_len = 4096;
var linear_to_srgb_table: [linear_to_srgb_table_len]f32 = undefined;
var linear_to_srgb_table_ready: bool = false;

fn ensureLinearToSrgbTable() void {
if (linear_to_srgb_table_ready) return;
for (&linear_to_srgb_table, 0..) |*value, index| {
value.* = referenceLinearToSrgb(@as(f32, @floatFromInt(index)) / (linear_to_srgb_table_len - 1));
}
linear_to_srgb_table_ready = true;
}

fn referenceLinearToSrgbLut(value: f32) f32 {
const index: usize = @intFromFloat(@round(std.math.clamp(value, 0, 1) * (linear_to_srgb_table_len - 1)));
return linear_to_srgb_table[index];
}

fn referencePremultiplySrgba8(pixel: [4]u8) ReferencePremultipliedLinearColor {
const alpha = @as(f32, @floatFromInt(pixel[3])) / 255.0;
return .{
Expand Down Expand Up @@ -1528,6 +1610,53 @@ fn blendRgba8(dst: [4]u8, src: Color, opacity: f32) [4]u8 {
};
}

/// Source-over with the source's fractional alpha split into COLOR alpha
/// and EDGE COVERAGE, so the blend space can key off what the alpha
/// means (see `CoverageBlend`).
///
/// Linear-light blending is reserved for the one case the split targets:
/// an effectively opaque source's anti-aliased fringe. Everything else —
/// sRGB-mode callers (glyphs), fully covered pixels, and translucent
/// sources — folds coverage into alpha and takes the historical sRGB
/// blend, byte for byte. That routing is also the cost story: interiors
/// (`coverage >= 1`) and washes never pay a decode/encode round-trip, so
/// the linear math runs only on the thin edge band, where the two table
/// lookups per channel replace `pow` evaluations.
fn blendRgba8Coverage(dst: [4]u8, src: Color, coverage: f32, opacity: f32, blend: CoverageBlend) [4]u8 {
const cov = std.math.clamp(coverage, 0, 1);
const src_a = std.math.clamp(src.a, 0, 1) * std.math.clamp(opacity, 0, 1);
if (blend == .srgb or cov <= 0 or cov >= 1 or src_a < 1) {
return blendRgba8(dst, referenceScaleColorAlpha(src, cov), opacity);
}

// Belt over the renderPass-level fills for direct callers (unit
// tests, future paths): two predictable branches per fringe pixel.
ensureSrgbToLinearByteTable();
ensureLinearToSrgbTable();

// Alpha stays in coverage space — it counts covered area, not light
// — so the alpha math is IDENTICAL to the sRGB path; only the color
// channels decode to linear light. `out_a >= cov > 0` here, so the
// straight-alpha un-premultiply divide is safe. The source decodes
// through the same byte quantization its coverage-1 pixels store,
// so a fringe converges exactly onto the interior bytes it borders.
const dst_a = @as(f32, @floatFromInt(dst[3])) / 255.0;
const out_a = cov + dst_a * (1 - cov);
const src_r = srgb_to_linear_byte_table[colorChannelToByte(src.r)];
const src_g = srgb_to_linear_byte_table[colorChannelToByte(src.g)];
const src_b = srgb_to_linear_byte_table[colorChannelToByte(src.b)];
const dst_r = srgb_to_linear_byte_table[dst[0]];
const dst_g = srgb_to_linear_byte_table[dst[1]];
const dst_b = srgb_to_linear_byte_table[dst[2]];
const dst_weight = dst_a * (1 - cov);
return .{
colorChannelToByte(referenceLinearToSrgbLut((src_r * cov + dst_r * dst_weight) / out_a)),
colorChannelToByte(referenceLinearToSrgbLut((src_g * cov + dst_g * dst_weight) / out_a)),
colorChannelToByte(referenceLinearToSrgbLut((src_b * cov + dst_b * dst_weight) / out_a)),
colorChannelToByte(out_a),
};
}

fn colorChannelToByte(value: f32) u8 {
return @intFromFloat(@round(std.math.clamp(value, 0, 1) * 255.0));
}
Expand Down
Loading
Loading