diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 75e61ebc0..dd405d20e 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -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"), }; diff --git a/src/renderer/metal/api.zig b/src/renderer/metal/api.zig index bd4f407cd..688878cb5 100644 --- a/src/renderer/metal/api.zig +++ b/src/renderer/metal/api.zig @@ -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 diff --git a/src/renderer/metal/shaders.zig b/src/renderer/metal/shaders.zig index b909a2f2a..e53feedc0 100644 --- a/src/renderer/metal/shaders.zig +++ b/src/renderer/metal/shaders.zig @@ -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. diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/cell.metal index 2a107402b..995a5a8bc 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/cell.metal @@ -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: {