renderer/metal: blend rendered cells/text in linear RGB space

DO NOT MERGE, right now this is a draft for demonstration.

Before, we were blending rendered text onto the background in sRGB
space, which is linear in perceived color/brightness but _not_ linear in
physical terms.

Correct alpha blending requires decoding from sRGB space into linear RGB
space, performing the blending, and then encoding back to sRGB space for
presenting. See here for a very good article on the whole topic of gamma
and how it relates to sRGB and text rendering:
https://blog.johnnovak.net/2016/09/21/what-every-coder-should-know-about-gamma/#antialiasing

In this demonstation, I do the decoding "by hand" in the shader, and
rely on Metal do re-encode as sRGB, by setting the right pixelFormat on
the colorAttachment.

One thing to note is that correct alpha blending in linear space makes
the rendered fonts look even thinner than they already are. When Kitty
introduced alpha blending in linear space, they introduced a
configuration that allows adjusting the gamma/contrast, which will
thicken up the fonts again. Fonts being thinner when doing correct
blending is also something that the article linked above explains, and
you can confirm this behavior for youself by running through an example
alpha blending calculation by hand, on pen and paper, say.

TODO/Notes before merging:

 - Add config that allows thickening fonts
 - Check performance, maybe introduce a LUT for doing the
   conversions/decode
 - Follow up: I've only looked at Metal, but the Linux/OpenGL renderer
   probably has the same issues. I could work on that one next.
This commit is contained in:
Aljoscha Krettek
2025-01-05 22:03:28 +01:00
parent 6181487bad
commit e90f667b69
4 changed files with 68 additions and 5 deletions

View File

@ -2113,6 +2113,7 @@ pub fn setScreenSize(
const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
break :init id_init;
};
// NOTE: Maybe these need to be changed as well, so custom shaders correctly decode/encode from/to sRGB?
desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.bgra8unorm));
desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width)));
desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height)));
@ -2143,6 +2144,7 @@ pub fn setScreenSize(
const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
break :init id_init;
};
// NOTE: Maybe these need to be changed as well, so custom shaders correctly decode/encode from/to sRGB?
desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.bgra8unorm));
desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width)));
desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height)));
@ -2859,6 +2861,7 @@ fn addGlyph(
.powerline => .fg_powerline,
};
// TOOD: Could convert from sRGB color to linear here, possibly using a LUT.
try self.cells.add(self.alloc, .text, .{
.mode = mode,
.grid_pos = .{ @intCast(x), @intCast(y) },
@ -3043,9 +3046,20 @@ fn syncAtlasTexture(device: objc.Object, atlas: *const font.Atlas, texture: *obj
/// Initialize a MTLTexture object for the given atlas.
fn initAtlasTexture(device: objc.Object, atlas: *const font.Atlas) !objc.Object {
// Determine our pixel format
//
// This is subtle: color pixel data we write to the Atlas is in sRGB
// format, and setting that here as the format makes sure that when we're
// sampling the atlas texture in the fragment shader we get a proper
// conversion from sRGB to linear space. Which latter we need to get
// correct alpha blending/antialiasing.
//
// Importantly, though, a grayscale texture is not used as color: we sample
// it in the fragment shader and treat the value we get as an alpha value
// that we apply to the desired font color. We _don't_ want sRGB
// encode/decode to mess with that value.
const pixel_format: mtl.MTLPixelFormat = switch (atlas.format) {
.grayscale => .r8unorm,
.rgba => .bgra8unorm,
.rgba => .bgra8unorm_srgb,
else => @panic("unsupported atlas format for Metal texture"),
};

View File

@ -74,6 +74,7 @@ pub const MTLPixelFormat = enum(c_ulong) {
rgba8unorm = 70,
rgba8uint = 73,
bgra8unorm = 80,
bgra8unorm_srgb = 81,
};
/// https://developer.apple.com/documentation/metal/mtlpurgeablestate?language=objc

View File

@ -427,8 +427,12 @@ fn initCellTextPipeline(device: objc.Object, library: objc.Object) !objc.Object
.{@as(c_ulong, 0)},
);
// Value is MTLPixelFormatBGRA8Unorm
attachment.setProperty("pixelFormat", @as(c_ulong, 80));
// Setting this to a sRGB pixel format means a) that Metal will encode
// the result of alpha blending (in linear RGB space) back into sRGB
// space, and b) that the destination color (aka background color in
// case we're rendering text on a background) is decoded to linear
// before alpha blending.
attachment.setProperty("pixelFormat", @as(c_ulong, @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)));
// Blending. This is required so that our text we render on top
// of our drawable properly blends into the bg.

View File

@ -35,6 +35,19 @@ float luminance_component(float c) {
}
}
// Also known as sRGB decode.
//
// NOTE: We might want to use a LUT, either within the shader or before handing
// in colors on the CPU side. I'd run benchmarks to see how much of a benefit
// using a LUT is actually.
float srgb_to_lin(float c) {
if (c <= 0.0404482362771082f) {
return c / 12.92;
} else {
return pow(((c + 0.055) / 1.055), 2.4);
}
}
float relative_luminance(float3 color) {
color.r = luminance_component(color.r);
color.g = luminance_component(color.g);
@ -222,6 +235,15 @@ vertex CellTextVertexOut cell_text_vertex(
CellTextVertexOut out;
out.mode = in.mode;
// NOTE: Instead of doing the sRGB in the fragment shader we could do it here.
// Meaning we would have to do a lot less work but be careful and do it in all
// the places where we pass through a color. For this demonstration I'm doing
// it right "at the edge", in the fragment shader.
//
// Will be interesting to run benchmarks with the different approaches:
// - conversion in fragment shader
// - conversion in vertex shader
// - using a LUT
out.color = float4(in.color) / 255.0f;
// === Grid Cell ===
@ -286,6 +308,8 @@ vertex CellTextVertexOut cell_text_vertex(
// have different colors as some parts are displayed via background colors).
if (uniforms.min_contrast > 1.0f && in.mode == MODE_TEXT) {
float4 bg_color = float4(bg_colors[in.grid_pos.y * uniforms.grid_size.x + in.grid_pos.x]) / 255.0f;
// NOTE: We'd also have to decode from sRGB here, if doing the decode in the
// vertex shader.
out.color = contrasted_color(uniforms.min_contrast, out.color, bg_color);
}
@ -299,6 +323,8 @@ vertex CellTextVertexOut cell_text_vertex(
) &&
in.grid_pos.y == uniforms.cursor_pos.y
) {
// NOTE: We'd also have to decode from sRGB here, if doing the decode in the
// vertex shader.
out.color = float4(uniforms.cursor_color) / 255.0f;
}
@ -322,16 +348,34 @@ fragment float4 cell_text_fragment(
case MODE_TEXT_CONSTRAINED:
case MODE_TEXT_POWERLINE:
case MODE_TEXT: {
// Setting a pixel format of sRGB only gives us "free" encode/decode to
// and from sRGB when we access textures/render targets, either when
// reading/sampling or writing. The user-configured color is passed in as
// data, so we don't get automatic conversion and have to either do it on
// the CPU side, in the vertex shader, or here.
// N.B. Purposefully using index rather than something like .r, to avoid
// ordering problems.
float3 in_color_lin = float3(srgb_to_lin(in.color[0]), srgb_to_lin(in.color[1]), srgb_to_lin(in.color[2]));
// We premult the alpha to our whole color since our blend function
// uses One/OneMinusSourceAlpha to avoid blurry edges.
// We first premult our given color.
float4 premult = float4(in.color.rgb * in.color.a, in.color.a);
float4 premult = float4(in_color_lin * in.color.a, in.color.a);
// Then premult the texture color
float a = textureGrayscale.sample(textureSampler, in.tex_coord).r;
premult = premult * a;
return premult;
// For debugging/testing Metal alpha blending behavior.
//
// Simulate with a known input color/alpha. This will yield blocks of color
// instead of text, because we don't multiply in the texture alpha.
// float alpha = 0.5;
// float text_lin = srgb_to_lin(in.color.r);
// float text_blended = text_lin * alpha;
// return float4(text_blended, text_blended, text_blended, alpha);
}
case MODE_TEXT_COLOR: {