mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
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:
@ -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"),
|
||||
};
|
||||
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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: {
|
||||
|
Reference in New Issue
Block a user