Metal alpha blending fixes + color handling improvements (#4913)

This PR addresses #2125 for the Metal renderer. Both options are
available: "Apple-style" blending where colors are blended in a wide
gamut color space, which reduces but does not eliminate artifacts; and
linear blending where colors are blended in linear RGB.

Because this doesn't add support for linear blending on Linux, I don't
know whether the issue should be closed or not.

### List of changes in no particular order
- We now set the layer's color space in the renderer not in the apprt
- We always set the layer to Display P3 color spaces
- If the user hasn't configured their `window-colorspace` to
`display-p3` then terminal colors are automatically converted from sRGB
to the corresponding Display P3 color in the shader
- Background color is not set with the clear color anymore, instead we
explicitly set all bg cell colors since this is needed for minimum
contrast to not break with dark text on the default bg color (try it
out, it forces it fully white right now), and we just draw the
background as a part of the bg cells shader. Note: We may want to move
the main background color to be the `backgroundColor` property on the
`CAMetalLayer`, because this should fix the flash of transparency during
startup (#4516) and the weirdness at the edge of the window when
resizing. I didn't make that a part of this PR because it requires
further changes and my changes are already pretty significant, but I can
make it a follow-up.
- Added a config option for changing alpha blending between "native"
blending, where colors are just blended directly in sRGB (or Display P3)
and linear blending, where colors are blended in linear space.
- Added a config option for an experimental technique that I think works
pretty well which compensates for the perceptual thinning and thickening
of dark and light text respectively when using linear blending.
- Custom shaders can now be hot reloaded with config reloads.
- Fixed a bug that was revealed when I changed how we handle
backgrounds, page widths weren't being set while cloning the screen.

### Main takeaways
Color blending now matches nearly identically to Apple apps like
Terminal.app and TextEdit, not *quite* identical in worst case
scenarios, off by the tiniest bit, because the default color space is
*slightly* different than Display P3.

Linear alpha blending is now available for mac users who prefer more
accurate color reproduction, and personally I think it looks very nice
with the alpha correction turned on, I will be daily driving that
configuration.

### Future work
- Handle primary background color with `CALayer.backgroundColor` instead
of in shader, to avoid issues around edges when resizing.
- Parse color space info directly from ICC profiles and compute the
color conversion matrix dynamically, and pass it as a uniform to the
shaders.
- Port linear blending option to OpenGL.
- Maybe support wide gamut images (right now all images are assumed to
be sRGB).
This commit is contained in:
Mitchell Hashimoto
2025-01-13 14:12:37 -08:00
committed by GitHub
11 changed files with 629 additions and 207 deletions

View File

@ -375,19 +375,6 @@ class QuickTerminalController: BaseTerminalController {
// Some APIs such as window blur have no effect unless the window is visible. // Some APIs such as window blur have no effect unless the window is visible.
guard window.isVisible else { return } guard window.isVisible else { return }
// Terminals typically operate in sRGB color space and macOS defaults
// to "native" which is typically P3. There is a lot more resources
// covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
// Ghostty defaults to sRGB but this can be overridden.
switch (self.derivedConfig.windowColorspace) {
case "display-p3":
window.colorSpace = .displayP3
case "srgb":
fallthrough
default:
window.colorSpace = .sRGB
}
// If we have window transparency then set it transparent. Otherwise set it opaque. // If we have window transparency then set it transparent. Otherwise set it opaque.
if (self.derivedConfig.backgroundOpacity < 1) { if (self.derivedConfig.backgroundOpacity < 1) {
window.isOpaque = false window.isOpaque = false
@ -457,7 +444,6 @@ class QuickTerminalController: BaseTerminalController {
let quickTerminalAnimationDuration: Double let quickTerminalAnimationDuration: Double
let quickTerminalAutoHide: Bool let quickTerminalAutoHide: Bool
let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior
let windowColorspace: String
let backgroundOpacity: Double let backgroundOpacity: Double
init() { init() {
@ -465,7 +451,6 @@ class QuickTerminalController: BaseTerminalController {
self.quickTerminalAnimationDuration = 0.2 self.quickTerminalAnimationDuration = 0.2
self.quickTerminalAutoHide = true self.quickTerminalAutoHide = true
self.quickTerminalSpaceBehavior = .move self.quickTerminalSpaceBehavior = .move
self.windowColorspace = ""
self.backgroundOpacity = 1.0 self.backgroundOpacity = 1.0
} }
@ -474,7 +459,6 @@ class QuickTerminalController: BaseTerminalController {
self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration
self.quickTerminalAutoHide = config.quickTerminalAutoHide self.quickTerminalAutoHide = config.quickTerminalAutoHide
self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior
self.windowColorspace = config.windowColorspace
self.backgroundOpacity = config.backgroundOpacity self.backgroundOpacity = config.backgroundOpacity
} }
} }

View File

@ -366,19 +366,6 @@ class TerminalController: BaseTerminalController {
// If window decorations are disabled, remove our title // If window decorations are disabled, remove our title
if (!config.windowDecorations) { window.styleMask.remove(.titled) } if (!config.windowDecorations) { window.styleMask.remove(.titled) }
// Terminals typically operate in sRGB color space and macOS defaults
// to "native" which is typically P3. There is a lot more resources
// covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
// Ghostty defaults to sRGB but this can be overridden.
switch (config.windowColorspace) {
case "display-p3":
window.colorSpace = .displayP3
case "srgb":
fallthrough
default:
window.colorSpace = .sRGB
}
// If we have only a single surface (no splits) and that surface requested // If we have only a single surface (no splits) and that surface requested
// an initial size then we set it here now. // an initial size then we set it here now.
if case let .leaf(leaf) = surfaceTree { if case let .leaf(leaf) = surfaceTree {

View File

@ -132,15 +132,6 @@ extension Ghostty {
return v return v
} }
var windowColorspace: String {
guard let config = self.config else { return "" }
var v: UnsafePointer<Int8>? = nil
let key = "window-colorspace"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "" }
guard let ptr = v else { return "" }
return String(cString: ptr)
}
var windowSaveState: String { var windowSaveState: String {
guard let config = self.config else { return "" } guard let config = self.config else { return "" }
var v: UnsafePointer<Int8>? = nil var v: UnsafePointer<Int8>? = nil

View File

@ -18,9 +18,72 @@ pub const ColorSpace = opaque {
) orelse Allocator.Error.OutOfMemory; ) orelse Allocator.Error.OutOfMemory;
} }
pub fn createNamed(name: Name) Allocator.Error!*ColorSpace {
return @as(
?*ColorSpace,
@ptrFromInt(@intFromPtr(c.CGColorSpaceCreateWithName(name.cfstring()))),
) orelse Allocator.Error.OutOfMemory;
}
pub fn release(self: *ColorSpace) void { pub fn release(self: *ColorSpace) void {
c.CGColorSpaceRelease(@ptrCast(self)); c.CGColorSpaceRelease(@ptrCast(self));
} }
pub const Name = enum {
/// This color space uses the DCI P3 primaries, a D65 white point, and
/// the sRGB transfer function.
displayP3,
/// The Display P3 color space with a linear transfer function and
/// extended-range values.
extendedLinearDisplayP3,
/// The sRGB colorimetry and non-linear transfer function are specified
/// in IEC 61966-2-1.
sRGB,
/// This color space has the same colorimetry as `sRGB`, but uses a
/// linear transfer function.
linearSRGB,
/// This color space has the same colorimetry as `sRGB`, but you can
/// encode component values below `0.0` and above `1.0`. Negative values
/// are encoded as the signed reflection of the original encoding
/// function, as shown in the formula below:
/// ```
/// extendedTransferFunction(x) = sign(x) * sRGBTransferFunction(abs(x))
/// ```
extendedSRGB,
/// This color space has the same colorimetry as `sRGB`; in addition,
/// you may encode component values below `0.0` and above `1.0`.
extendedLinearSRGB,
/// ...
genericGrayGamma2_2,
/// ...
linearGray,
/// This color space has the same colorimetry as `genericGrayGamma2_2`,
/// but you can encode component values below `0.0` and above `1.0`.
/// Negative values are encoded as the signed reflection of the
/// original encoding function, as shown in the formula below:
/// ```
/// extendedGrayTransferFunction(x) = sign(x) * gamma22Function(abs(x))
/// ```
extendedGray,
/// This color space has the same colorimetry as `linearGray`; in
/// addition, you may encode component values below `0.0` and above `1.0`.
extendedLinearGray,
fn cfstring(self: Name) c.CFStringRef {
return switch (self) {
.displayP3 => c.kCGColorSpaceDisplayP3,
.extendedLinearDisplayP3 => c.kCGColorSpaceExtendedLinearDisplayP3,
.sRGB => c.kCGColorSpaceSRGB,
.extendedSRGB => c.kCGColorSpaceExtendedSRGB,
.linearSRGB => c.kCGColorSpaceLinearSRGB,
.extendedLinearSRGB => c.kCGColorSpaceExtendedLinearSRGB,
.genericGrayGamma2_2 => c.kCGColorSpaceGenericGrayGamma2_2,
.extendedGray => c.kCGColorSpaceExtendedGray,
.linearGray => c.kCGColorSpaceLinearGray,
.extendedLinearGray => c.kCGColorSpaceExtendedLinearGray,
};
}
};
}; };
test { test {

View File

@ -248,6 +248,32 @@ const c = @cImport({
/// This is currently only supported on macOS. /// This is currently only supported on macOS.
@"font-thicken-strength": u8 = 255, @"font-thicken-strength": u8 = 255,
/// What color space to use when performing alpha blending.
///
/// This affects how text looks for different background/foreground color pairs.
///
/// Valid values:
///
/// * `native` - Perform alpha blending in the native color space for the OS.
/// On macOS this corresponds to Display P3, and on Linux it's sRGB.
///
/// * `linear` - Perform alpha blending in linear space. This will eliminate
/// the darkening artifacts around the edges of text that are very visible
/// when certain color combinations are used (e.g. red / green), but makes
/// dark text look much thinner than normal and light text much thicker.
/// This is also sometimes known as "gamma correction".
/// (Currently only supported on macOS. Has no effect on Linux.)
///
/// * `linear-corrected` - Corrects the thinning/thickening effect of linear
/// by applying a correction curve to the text alpha depending on its
/// brightness. This compensates for the thinning and makes the weight of
/// most text appear very similar to when it's blended non-linearly.
///
/// Note: This setting affects more than just text, images will also be blended
/// in the selected color space, and custom shaders will receive colors in that
/// color space as well.
@"text-blending": TextBlending = .native,
/// All of the configurations behavior adjust various metrics determined by the /// All of the configurations behavior adjust various metrics determined by the
/// font. The values can be integers (1, -1, etc.) or a percentage (20%, -15%, /// font. The values can be integers (1, -1, etc.) or a percentage (20%, -15%,
/// etc.). In each case, the values represent the amount to change the original /// etc.). In each case, the values represent the amount to change the original
@ -5753,6 +5779,20 @@ pub const GraphemeWidthMethod = enum {
unicode, unicode,
}; };
/// See text-blending
pub const TextBlending = enum {
native,
linear,
@"linear-corrected",
pub fn isLinear(self: TextBlending) bool {
return switch (self) {
.native => false,
.linear, .@"linear-corrected" => true,
};
}
};
/// See freetype-load-flag /// See freetype-load-flag
pub const FreetypeLoadFlags = packed struct { pub const FreetypeLoadFlags = packed struct {
// The defaults here at the time of writing this match the defaults // The defaults here at the time of writing this match the defaults

View File

@ -343,13 +343,12 @@ pub const Face = struct {
} = if (!self.isColorGlyph(glyph_index)) .{ } = if (!self.isColorGlyph(glyph_index)) .{
.color = false, .color = false,
.depth = 1, .depth = 1,
.space = try macos.graphics.ColorSpace.createDeviceGray(), .space = try macos.graphics.ColorSpace.createNamed(.linearGray),
.context_opts = @intFromEnum(macos.graphics.BitmapInfo.alpha_mask) & .context_opts = @intFromEnum(macos.graphics.ImageAlphaInfo.only),
@intFromEnum(macos.graphics.ImageAlphaInfo.only),
} else .{ } else .{
.color = true, .color = true,
.depth = 4, .depth = 4,
.space = try macos.graphics.ColorSpace.createDeviceRGB(), .space = try macos.graphics.ColorSpace.createNamed(.displayP3),
.context_opts = @intFromEnum(macos.graphics.BitmapInfo.byte_order_32_little) | .context_opts = @intFromEnum(macos.graphics.BitmapInfo.byte_order_32_little) |
@intFromEnum(macos.graphics.ImageAlphaInfo.premultiplied_first), @intFromEnum(macos.graphics.ImageAlphaInfo.premultiplied_first),
}; };

View File

@ -21,6 +21,7 @@ const renderer = @import("../renderer.zig");
const math = @import("../math.zig"); const math = @import("../math.zig");
const Surface = @import("../Surface.zig"); const Surface = @import("../Surface.zig");
const link = @import("link.zig"); const link = @import("link.zig");
const graphics = macos.graphics;
const fgMode = @import("cell.zig").fgMode; const fgMode = @import("cell.zig").fgMode;
const isCovering = @import("cell.zig").isCovering; const isCovering = @import("cell.zig").isCovering;
const shadertoy = @import("shadertoy.zig"); const shadertoy = @import("shadertoy.zig");
@ -105,10 +106,6 @@ default_cursor_color: ?terminal.color.RGB,
/// foreground color as the cursor color. /// foreground color as the cursor color.
cursor_invert: bool, cursor_invert: bool,
/// The current frame background color. This is only updated during
/// the updateFrame method.
current_background_color: terminal.color.RGB,
/// The current set of cells to render. This is rebuilt on every frame /// The current set of cells to render. This is rebuilt on every frame
/// but we keep this around so that we don't reallocate. Each set of /// but we keep this around so that we don't reallocate. Each set of
/// cells goes into a separate shader. /// cells goes into a separate shader.
@ -390,6 +387,8 @@ pub const DerivedConfig = struct {
custom_shaders: configpkg.RepeatablePath, custom_shaders: configpkg.RepeatablePath,
links: link.Set, links: link.Set,
vsync: bool, vsync: bool,
colorspace: configpkg.Config.WindowColorspace,
blending: configpkg.Config.TextBlending,
pub fn init( pub fn init(
alloc_gpa: Allocator, alloc_gpa: Allocator,
@ -460,7 +459,8 @@ pub const DerivedConfig = struct {
.custom_shaders = custom_shaders, .custom_shaders = custom_shaders,
.links = links, .links = links,
.vsync = config.@"window-vsync", .vsync = config.@"window-vsync",
.colorspace = config.@"window-colorspace",
.blending = config.@"text-blending",
.arena = arena, .arena = arena,
}; };
} }
@ -490,10 +490,6 @@ pub fn surfaceInit(surface: *apprt.Surface) !void {
} }
pub fn init(alloc: Allocator, options: renderer.Options) !Metal { pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
var arena = ArenaAllocator.init(alloc);
defer arena.deinit();
const arena_alloc = arena.allocator();
const ViewInfo = struct { const ViewInfo = struct {
view: objc.Object, view: objc.Object,
scaleFactor: f64, scaleFactor: f64,
@ -512,7 +508,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
nswindow.getProperty(?*anyopaque, "contentView").?, nswindow.getProperty(?*anyopaque, "contentView").?,
); );
const scaleFactor = nswindow.getProperty( const scaleFactor = nswindow.getProperty(
macos.graphics.c.CGFloat, graphics.c.CGFloat,
"backingScaleFactor", "backingScaleFactor",
); );
@ -553,6 +549,29 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
layer.setProperty("opaque", options.config.background_opacity >= 1); layer.setProperty("opaque", options.config.background_opacity >= 1);
layer.setProperty("displaySyncEnabled", options.config.vsync); layer.setProperty("displaySyncEnabled", options.config.vsync);
// Set our layer's pixel format appropriately.
layer.setProperty(
"pixelFormat",
// Using an `*_srgb` pixel format makes Metal gamma encode
// the pixels written to it *after* blending, which means
// we get linear alpha blending rather than gamma-incorrect
// blending.
if (options.config.blending.isLinear())
@intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)
else
@intFromEnum(mtl.MTLPixelFormat.bgra8unorm),
);
// Set our layer's color space to Display P3.
// This allows us to have "Apple-style" alpha blending,
// since it seems to be the case that Apple apps like
// Terminal and TextEdit render text in the display's
// color space using converted colors, which reduces,
// but does not fully eliminate blending artifacts.
const colorspace = try graphics.ColorSpace.createNamed(.displayP3);
errdefer colorspace.release();
layer.setProperty("colorspace", colorspace);
// Make our view layer-backed with our Metal layer. On iOS views are // Make our view layer-backed with our Metal layer. On iOS views are
// always layer backed so we don't need to do this. But on iOS the // always layer backed so we don't need to do this. But on iOS the
// caller MUST be sure to set the layerClass to CAMetalLayer. // caller MUST be sure to set the layerClass to CAMetalLayer.
@ -578,54 +597,6 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
}); });
errdefer font_shaper.deinit(); errdefer font_shaper.deinit();
// Load our custom shaders
const custom_shaders: []const [:0]const u8 = shadertoy.loadFromFiles(
arena_alloc,
options.config.custom_shaders,
.msl,
) catch |err| err: {
log.warn("error loading custom shaders err={}", .{err});
break :err &.{};
};
// If we have custom shaders then setup our state
var custom_shader_state: ?CustomShaderState = state: {
if (custom_shaders.len == 0) break :state null;
// Build our sampler for our texture
var sampler = try mtl_sampler.Sampler.init(gpu_state.device);
errdefer sampler.deinit();
break :state .{
// Resolution and screen textures will be fixed up by first
// call to setScreenSize. Draw calls will bail out early if
// the screen size hasn't been set yet, so it won't error.
.front_texture = undefined,
.back_texture = undefined,
.sampler = sampler,
.uniforms = .{
.resolution = .{ 0, 0, 1 },
.time = 1,
.time_delta = 1,
.frame_rate = 1,
.frame = 1,
.channel_time = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4,
.channel_resolution = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4,
.mouse = .{ 0, 0, 0, 0 },
.date = .{ 0, 0, 0, 0 },
.sample_rate = 1,
},
.first_frame_time = try std.time.Instant.now(),
.last_frame_time = try std.time.Instant.now(),
};
};
errdefer if (custom_shader_state) |*state| state.deinit();
// Initialize our shaders
var shaders = try Shaders.init(alloc, gpu_state.device, custom_shaders);
errdefer shaders.deinit(alloc);
// Initialize all the data that requires a critical font section. // Initialize all the data that requires a critical font section.
const font_critical: struct { const font_critical: struct {
metrics: font.Metrics, metrics: font.Metrics,
@ -661,7 +632,6 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
.cursor_color = null, .cursor_color = null,
.default_cursor_color = options.config.cursor_color, .default_cursor_color = options.config.cursor_color,
.cursor_invert = options.config.cursor_invert, .cursor_invert = options.config.cursor_invert,
.current_background_color = options.config.background,
// Render state // Render state
.cells = .{}, .cells = .{},
@ -674,7 +644,16 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
.min_contrast = options.config.min_contrast, .min_contrast = options.config.min_contrast,
.cursor_pos = .{ std.math.maxInt(u16), std.math.maxInt(u16) }, .cursor_pos = .{ std.math.maxInt(u16), std.math.maxInt(u16) },
.cursor_color = undefined, .cursor_color = undefined,
.bg_color = .{
options.config.background.r,
options.config.background.g,
options.config.background.b,
@intFromFloat(@round(options.config.background_opacity * 255.0)),
},
.cursor_wide = false, .cursor_wide = false,
.use_display_p3 = options.config.colorspace == .@"display-p3",
.use_linear_blending = options.config.blending.isLinear(),
.use_experimental_linear_correction = options.config.blending == .@"linear-corrected",
}, },
// Fonts // Fonts
@ -682,16 +661,18 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
.font_shaper = font_shaper, .font_shaper = font_shaper,
.font_shaper_cache = font.ShaperCache.init(), .font_shaper_cache = font.ShaperCache.init(),
// Shaders // Shaders (initialized below)
.shaders = shaders, .shaders = undefined,
// Metal stuff // Metal stuff
.layer = layer, .layer = layer,
.display_link = display_link, .display_link = display_link,
.custom_shader_state = custom_shader_state, .custom_shader_state = null,
.gpu_state = gpu_state, .gpu_state = gpu_state,
}; };
try result.initShaders();
// Do an initialize screen size setup to ensure our undefined values // Do an initialize screen size setup to ensure our undefined values
// above are initialized. // above are initialized.
try result.setScreenSize(result.size); try result.setScreenSize(result.size);
@ -723,11 +704,82 @@ pub fn deinit(self: *Metal) void {
} }
self.image_placements.deinit(self.alloc); self.image_placements.deinit(self.alloc);
self.deinitShaders();
self.* = undefined;
}
fn deinitShaders(self: *Metal) void {
if (self.custom_shader_state) |*state| state.deinit(); if (self.custom_shader_state) |*state| state.deinit();
self.shaders.deinit(self.alloc); self.shaders.deinit(self.alloc);
}
self.* = undefined; fn initShaders(self: *Metal) !void {
var arena = ArenaAllocator.init(self.alloc);
defer arena.deinit();
const arena_alloc = arena.allocator();
// Load our custom shaders
const custom_shaders: []const [:0]const u8 = shadertoy.loadFromFiles(
arena_alloc,
self.config.custom_shaders,
.msl,
) catch |err| err: {
log.warn("error loading custom shaders err={}", .{err});
break :err &.{};
};
var custom_shader_state: ?CustomShaderState = state: {
if (custom_shaders.len == 0) break :state null;
// Build our sampler for our texture
var sampler = try mtl_sampler.Sampler.init(self.gpu_state.device);
errdefer sampler.deinit();
break :state .{
// Resolution and screen textures will be fixed up by first
// call to setScreenSize. Draw calls will bail out early if
// the screen size hasn't been set yet, so it won't error.
.front_texture = undefined,
.back_texture = undefined,
.sampler = sampler,
.uniforms = .{
.resolution = .{ 0, 0, 1 },
.time = 1,
.time_delta = 1,
.frame_rate = 1,
.frame = 1,
.channel_time = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4,
.channel_resolution = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4,
.mouse = .{ 0, 0, 0, 0 },
.date = .{ 0, 0, 0, 0 },
.sample_rate = 1,
},
.first_frame_time = try std.time.Instant.now(),
.last_frame_time = try std.time.Instant.now(),
};
};
errdefer if (custom_shader_state) |*state| state.deinit();
var shaders = try Shaders.init(
self.alloc,
self.gpu_state.device,
custom_shaders,
// Using an `*_srgb` pixel format makes Metal gamma encode
// the pixels written to it *after* blending, which means
// we get linear alpha blending rather than gamma-incorrect
// blending.
if (self.config.blending.isLinear())
mtl.MTLPixelFormat.bgra8unorm_srgb
else
mtl.MTLPixelFormat.bgra8unorm,
);
errdefer shaders.deinit(self.alloc);
self.shaders = shaders;
self.custom_shader_state = custom_shader_state;
} }
/// This is called just prior to spinning up the renderer thread for /// This is called just prior to spinning up the renderer thread for
@ -1111,7 +1163,12 @@ pub fn updateFrame(
self.cells_viewport = critical.viewport_pin; self.cells_viewport = critical.viewport_pin;
// Update our background color // Update our background color
self.current_background_color = critical.bg; self.uniforms.bg_color = .{
critical.bg.r,
critical.bg.g,
critical.bg.b,
@intFromFloat(@round(self.config.background_opacity * 255.0)),
};
// Go through our images and see if we need to setup any textures. // Go through our images and see if we need to setup any textures.
{ {
@ -1233,10 +1290,10 @@ pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void {
attachment.setProperty("storeAction", @intFromEnum(mtl.MTLStoreAction.store)); attachment.setProperty("storeAction", @intFromEnum(mtl.MTLStoreAction.store));
attachment.setProperty("texture", screen_texture.value); attachment.setProperty("texture", screen_texture.value);
attachment.setProperty("clearColor", mtl.MTLClearColor{ attachment.setProperty("clearColor", mtl.MTLClearColor{
.red = @as(f32, @floatFromInt(self.current_background_color.r)) / 255 * self.config.background_opacity, .red = 0.0,
.green = @as(f32, @floatFromInt(self.current_background_color.g)) / 255 * self.config.background_opacity, .green = 0.0,
.blue = @as(f32, @floatFromInt(self.current_background_color.b)) / 255 * self.config.background_opacity, .blue = 0.0,
.alpha = self.config.background_opacity, .alpha = 0.0,
}); });
} }
@ -1252,19 +1309,19 @@ pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void {
defer encoder.msgSend(void, objc.sel("endEncoding"), .{}); defer encoder.msgSend(void, objc.sel("endEncoding"), .{});
// Draw background images first // Draw background images first
try self.drawImagePlacements(encoder, self.image_placements.items[0..self.image_bg_end]); try self.drawImagePlacements(encoder, frame, self.image_placements.items[0..self.image_bg_end]);
// Then draw background cells // Then draw background cells
try self.drawCellBgs(encoder, frame); try self.drawCellBgs(encoder, frame);
// Then draw images under text // Then draw images under text
try self.drawImagePlacements(encoder, self.image_placements.items[self.image_bg_end..self.image_text_end]); try self.drawImagePlacements(encoder, frame, self.image_placements.items[self.image_bg_end..self.image_text_end]);
// Then draw fg cells // Then draw fg cells
try self.drawCellFgs(encoder, frame, fg_count); try self.drawCellFgs(encoder, frame, fg_count);
// Then draw remaining images // Then draw remaining images
try self.drawImagePlacements(encoder, self.image_placements.items[self.image_text_end..]); try self.drawImagePlacements(encoder, frame, self.image_placements.items[self.image_text_end..]);
} }
// If we have custom shaders, then we render them. // If we have custom shaders, then we render them.
@ -1457,6 +1514,7 @@ fn drawPostShader(
fn drawImagePlacements( fn drawImagePlacements(
self: *Metal, self: *Metal,
encoder: objc.Object, encoder: objc.Object,
frame: *const FrameState,
placements: []const mtl_image.Placement, placements: []const mtl_image.Placement,
) !void { ) !void {
if (placements.len == 0) return; if (placements.len == 0) return;
@ -1468,15 +1526,16 @@ fn drawImagePlacements(
.{self.shaders.image_pipeline.value}, .{self.shaders.image_pipeline.value},
); );
// Set our uniform, which is the only shared buffer // Set our uniforms
encoder.msgSend( encoder.msgSend(
void, void,
objc.sel("setVertexBytes:length:atIndex:"), objc.sel("setVertexBuffer:offset:atIndex:"),
.{ .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) },
@as(*const anyopaque, @ptrCast(&self.uniforms)), );
@as(c_ulong, @sizeOf(@TypeOf(self.uniforms))), encoder.msgSend(
@as(c_ulong, 1), void,
}, objc.sel("setFragmentBuffer:offset:atIndex:"),
.{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) },
); );
for (placements) |placement| { for (placements) |placement| {
@ -1588,6 +1647,11 @@ fn drawCellBgs(
); );
// Set our buffers // Set our buffers
encoder.msgSend(
void,
objc.sel("setVertexBuffer:offset:atIndex:"),
.{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) },
);
encoder.msgSend( encoder.msgSend(
void, void,
objc.sel("setFragmentBuffer:offset:atIndex:"), objc.sel("setFragmentBuffer:offset:atIndex:"),
@ -1647,18 +1711,17 @@ fn drawCellFgs(
encoder.msgSend( encoder.msgSend(
void, void,
objc.sel("setFragmentTexture:atIndex:"), objc.sel("setFragmentTexture:atIndex:"),
.{ .{ frame.grayscale.value, @as(c_ulong, 0) },
frame.grayscale.value,
@as(c_ulong, 0),
},
); );
encoder.msgSend( encoder.msgSend(
void, void,
objc.sel("setFragmentTexture:atIndex:"), objc.sel("setFragmentTexture:atIndex:"),
.{ .{ frame.color.value, @as(c_ulong, 1) },
frame.color.value, );
@as(c_ulong, 1), encoder.msgSend(
}, void,
objc.sel("setFragmentBuffer:offset:atIndex:"),
.{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 2) },
); );
encoder.msgSend( encoder.msgSend(
@ -2003,17 +2066,47 @@ pub fn changeConfig(self: *Metal, config: *DerivedConfig) !void {
// Set our new minimum contrast // Set our new minimum contrast
self.uniforms.min_contrast = config.min_contrast; self.uniforms.min_contrast = config.min_contrast;
// Set our new color space and blending
self.uniforms.use_display_p3 = config.colorspace == .@"display-p3";
self.uniforms.use_linear_blending = config.blending.isLinear();
self.uniforms.use_experimental_linear_correction = config.blending == .@"linear-corrected";
// Set our new colors // Set our new colors
self.default_background_color = config.background; self.default_background_color = config.background;
self.default_foreground_color = config.foreground; self.default_foreground_color = config.foreground;
self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null; self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null;
self.cursor_invert = config.cursor_invert; self.cursor_invert = config.cursor_invert;
const old_blending = self.config.blending;
const old_custom_shaders = self.config.custom_shaders;
self.config.deinit(); self.config.deinit();
self.config = config.*; self.config = config.*;
// Reset our viewport to force a rebuild, in case of a font change. // Reset our viewport to force a rebuild, in case of a font change.
self.cells_viewport = null; self.cells_viewport = null;
// We reinitialize our shaders if our
// blending or custom shaders changed.
if (old_blending != config.blending or
!old_custom_shaders.equal(config.custom_shaders))
{
self.deinitShaders();
try self.initShaders();
// We call setScreenSize to reinitialize
// the textures used for custom shaders.
if (self.custom_shader_state != null) {
try self.setScreenSize(self.size);
}
// And we update our layer's pixel format appropriately.
self.layer.setProperty(
"pixelFormat",
if (config.blending.isLinear())
@intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)
else
@intFromEnum(mtl.MTLPixelFormat.bgra8unorm),
);
}
} }
/// Resize the screen. /// Resize the screen.
@ -2057,7 +2150,7 @@ pub fn setScreenSize(
} }
// Set the size of the drawable surface to the bounds // Set the size of the drawable surface to the bounds
self.layer.setProperty("drawableSize", macos.graphics.Size{ self.layer.setProperty("drawableSize", graphics.Size{
.width = @floatFromInt(size.screen.width), .width = @floatFromInt(size.screen.width),
.height = @floatFromInt(size.screen.height), .height = @floatFromInt(size.screen.height),
}); });
@ -2089,7 +2182,11 @@ pub fn setScreenSize(
.min_contrast = old.min_contrast, .min_contrast = old.min_contrast,
.cursor_pos = old.cursor_pos, .cursor_pos = old.cursor_pos,
.cursor_color = old.cursor_color, .cursor_color = old.cursor_color,
.bg_color = old.bg_color,
.cursor_wide = old.cursor_wide, .cursor_wide = old.cursor_wide,
.use_display_p3 = old.use_display_p3,
.use_linear_blending = old.use_linear_blending,
.use_experimental_linear_correction = old.use_experimental_linear_correction,
}; };
// Reset our cell contents if our grid size has changed. // Reset our cell contents if our grid size has changed.
@ -2124,7 +2221,17 @@ pub fn setScreenSize(
const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
break :init id_init; break :init id_init;
}; };
desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.bgra8unorm)); desc.setProperty(
"pixelFormat",
// Using an `*_srgb` pixel format makes Metal gamma encode
// the pixels written to it *after* blending, which means
// we get linear alpha blending rather than gamma-incorrect
// blending.
if (self.config.blending.isLinear())
@intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)
else
@intFromEnum(mtl.MTLPixelFormat.bgra8unorm),
);
desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width))); desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width)));
desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height))); desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height)));
desc.setProperty( desc.setProperty(
@ -2154,7 +2261,17 @@ pub fn setScreenSize(
const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
break :init id_init; break :init id_init;
}; };
desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.bgra8unorm)); desc.setProperty(
"pixelFormat",
// Using an `*_srgb` pixel format makes Metal gamma encode
// the pixels written to it *after* blending, which means
// we get linear alpha blending rather than gamma-incorrect
// blending.
if (self.config.blending.isLinear())
@intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)
else
@intFromEnum(mtl.MTLPixelFormat.bgra8unorm),
);
desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width))); desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width)));
desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height))); desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height)));
desc.setProperty( desc.setProperty(
@ -2466,8 +2583,10 @@ fn rebuildCells(
// Foreground alpha for this cell. // Foreground alpha for this cell.
const alpha: u8 = if (style.flags.faint) 175 else 255; const alpha: u8 = if (style.flags.faint) 175 else 255;
// If the cell has a background color, set it. // Set the cell's background color.
if (bg) |rgb| { {
const rgb = bg orelse self.background_color orelse self.default_background_color;
// Determine our background alpha. If we have transparency configured // Determine our background alpha. If we have transparency configured
// then this is dynamic depending on some situations. This is all // then this is dynamic depending on some situations. This is all
// in an attempt to make transparency look the best for various // in an attempt to make transparency look the best for various
@ -2477,23 +2596,20 @@ fn rebuildCells(
if (self.config.background_opacity >= 1) break :bg_alpha default; if (self.config.background_opacity >= 1) break :bg_alpha default;
// If we're selected, we do not apply background opacity // Cells that are selected should be fully opaque.
if (selected) break :bg_alpha default; if (selected) break :bg_alpha default;
// If we're reversed, do not apply background opacity // Cells that are reversed should be fully opaque.
if (style.flags.inverse) break :bg_alpha default; if (style.flags.inverse) break :bg_alpha default;
// If we have a background and its not the default background // Cells that have an explicit bg color, which does not
// then we apply background opacity // match the current surface bg, should be fully opaque.
if (style.bg(cell, color_palette) != null and !rgb.eql(self.background_color orelse self.default_background_color)) { if (bg != null and !rgb.eql(self.background_color orelse self.default_background_color)) {
break :bg_alpha default; break :bg_alpha default;
} }
// We apply background opacity. // Otherwise, we use the configured background opacity.
var bg_alpha: f64 = @floatFromInt(default); break :bg_alpha @intFromFloat(@round(self.config.background_opacity * 255.0));
bg_alpha *= self.config.background_opacity;
bg_alpha = @ceil(bg_alpha);
break :bg_alpha @intFromFloat(bg_alpha);
}; };
self.cells.bgCell(y, x).* = .{ self.cells.bgCell(y, x).* = .{

View File

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

View File

@ -13,9 +13,7 @@ const log = std.log.scoped(.metal);
pub const Shaders = struct { pub const Shaders = struct {
library: objc.Object, library: objc.Object,
/// The cell shader is the shader used to render the terminal cells. /// Renders cell foreground elements (text, decorations).
/// It is a single shader that is used for both the background and
/// foreground.
cell_text_pipeline: objc.Object, cell_text_pipeline: objc.Object,
/// The cell background shader is the shader used to render the /// The cell background shader is the shader used to render the
@ -40,17 +38,18 @@ pub const Shaders = struct {
alloc: Allocator, alloc: Allocator,
device: objc.Object, device: objc.Object,
post_shaders: []const [:0]const u8, post_shaders: []const [:0]const u8,
pixel_format: mtl.MTLPixelFormat,
) !Shaders { ) !Shaders {
const library = try initLibrary(device); const library = try initLibrary(device);
errdefer library.msgSend(void, objc.sel("release"), .{}); errdefer library.msgSend(void, objc.sel("release"), .{});
const cell_text_pipeline = try initCellTextPipeline(device, library); const cell_text_pipeline = try initCellTextPipeline(device, library, pixel_format);
errdefer cell_text_pipeline.msgSend(void, objc.sel("release"), .{}); errdefer cell_text_pipeline.msgSend(void, objc.sel("release"), .{});
const cell_bg_pipeline = try initCellBgPipeline(device, library); const cell_bg_pipeline = try initCellBgPipeline(device, library, pixel_format);
errdefer cell_bg_pipeline.msgSend(void, objc.sel("release"), .{}); errdefer cell_bg_pipeline.msgSend(void, objc.sel("release"), .{});
const image_pipeline = try initImagePipeline(device, library); const image_pipeline = try initImagePipeline(device, library, pixel_format);
errdefer image_pipeline.msgSend(void, objc.sel("release"), .{}); errdefer image_pipeline.msgSend(void, objc.sel("release"), .{});
const post_pipelines: []const objc.Object = initPostPipelines( const post_pipelines: []const objc.Object = initPostPipelines(
@ -58,6 +57,7 @@ pub const Shaders = struct {
device, device,
library, library,
post_shaders, post_shaders,
pixel_format,
) catch |err| err: { ) catch |err| err: {
// If an error happens while building postprocess shaders we // If an error happens while building postprocess shaders we
// want to just not use any postprocess shaders since we don't // want to just not use any postprocess shaders since we don't
@ -137,9 +137,29 @@ pub const Uniforms = extern struct {
cursor_pos: [2]u16 align(4), cursor_pos: [2]u16 align(4),
cursor_color: [4]u8 align(4), cursor_color: [4]u8 align(4),
// Whether the cursor is 2 cells wide. /// The background color for the whole surface.
bg_color: [4]u8 align(4),
/// Whether the cursor is 2 cells wide.
cursor_wide: bool align(1), cursor_wide: bool align(1),
/// Indicates that colors provided to the shader are already in
/// the P3 color space, so they don't need to be converted from
/// sRGB.
use_display_p3: bool align(1),
/// Indicates that the color attachments for the shaders have
/// an `*_srgb` pixel format, which means the shaders need to
/// output linear RGB colors rather than gamma encoded colors,
/// since blending will be performed in linear space and then
/// Metal itself will re-encode the colors for storage.
use_linear_blending: bool align(1),
/// Enables a weight correction step that makes text rendered
/// with linear alpha blending have a similar apparent weight
/// (thickness) to gamma-incorrect blending.
use_experimental_linear_correction: bool align(1) = false,
const PaddingExtend = packed struct(u8) { const PaddingExtend = packed struct(u8) {
left: bool = false, left: bool = false,
right: bool = false, right: bool = false,
@ -201,6 +221,7 @@ fn initPostPipelines(
device: objc.Object, device: objc.Object,
library: objc.Object, library: objc.Object,
shaders: []const [:0]const u8, shaders: []const [:0]const u8,
pixel_format: mtl.MTLPixelFormat,
) ![]const objc.Object { ) ![]const objc.Object {
// If we have no shaders, do nothing. // If we have no shaders, do nothing.
if (shaders.len == 0) return &.{}; if (shaders.len == 0) return &.{};
@ -220,7 +241,12 @@ fn initPostPipelines(
// Build each shader. Note we don't use "0.." to build our index // Build each shader. Note we don't use "0.." to build our index
// because we need to keep track of our length to clean up above. // because we need to keep track of our length to clean up above.
for (shaders) |source| { for (shaders) |source| {
pipelines[i] = try initPostPipeline(device, library, source); pipelines[i] = try initPostPipeline(
device,
library,
source,
pixel_format,
);
i += 1; i += 1;
} }
@ -232,6 +258,7 @@ fn initPostPipeline(
device: objc.Object, device: objc.Object,
library: objc.Object, library: objc.Object,
data: [:0]const u8, data: [:0]const u8,
pixel_format: mtl.MTLPixelFormat,
) !objc.Object { ) !objc.Object {
// Create our library which has the shader source // Create our library which has the shader source
const post_library = library: { const post_library = library: {
@ -301,8 +328,7 @@ fn initPostPipeline(
.{@as(c_ulong, 0)}, .{@as(c_ulong, 0)},
); );
// Value is MTLPixelFormatBGRA8Unorm attachment.setProperty("pixelFormat", @intFromEnum(pixel_format));
attachment.setProperty("pixelFormat", @as(c_ulong, 80));
} }
// Make our state // Make our state
@ -343,7 +369,11 @@ pub const CellText = extern struct {
}; };
/// Initialize the cell render pipeline for our shader library. /// Initialize the cell render pipeline for our shader library.
fn initCellTextPipeline(device: objc.Object, library: objc.Object) !objc.Object { fn initCellTextPipeline(
device: objc.Object,
library: objc.Object,
pixel_format: mtl.MTLPixelFormat,
) !objc.Object {
// Get our vertex and fragment functions // Get our vertex and fragment functions
const func_vert = func_vert: { const func_vert = func_vert: {
const str = try macos.foundation.String.createWithBytes( const str = try macos.foundation.String.createWithBytes(
@ -427,8 +457,7 @@ fn initCellTextPipeline(device: objc.Object, library: objc.Object) !objc.Object
.{@as(c_ulong, 0)}, .{@as(c_ulong, 0)},
); );
// Value is MTLPixelFormatBGRA8Unorm attachment.setProperty("pixelFormat", @intFromEnum(pixel_format));
attachment.setProperty("pixelFormat", @as(c_ulong, 80));
// Blending. This is required so that our text we render on top // Blending. This is required so that our text we render on top
// of our drawable properly blends into the bg. // of our drawable properly blends into the bg.
@ -458,11 +487,15 @@ fn initCellTextPipeline(device: objc.Object, library: objc.Object) !objc.Object
pub const CellBg = [4]u8; pub const CellBg = [4]u8;
/// Initialize the cell background render pipeline for our shader library. /// Initialize the cell background render pipeline for our shader library.
fn initCellBgPipeline(device: objc.Object, library: objc.Object) !objc.Object { fn initCellBgPipeline(
device: objc.Object,
library: objc.Object,
pixel_format: mtl.MTLPixelFormat,
) !objc.Object {
// Get our vertex and fragment functions // Get our vertex and fragment functions
const func_vert = func_vert: { const func_vert = func_vert: {
const str = try macos.foundation.String.createWithBytes( const str = try macos.foundation.String.createWithBytes(
"full_screen_vertex", "cell_bg_vertex",
.utf8, .utf8,
false, false,
); );
@ -507,8 +540,7 @@ fn initCellBgPipeline(device: objc.Object, library: objc.Object) !objc.Object {
.{@as(c_ulong, 0)}, .{@as(c_ulong, 0)},
); );
// Value is MTLPixelFormatBGRA8Unorm attachment.setProperty("pixelFormat", @intFromEnum(pixel_format));
attachment.setProperty("pixelFormat", @as(c_ulong, 80));
// Blending. This is required so that our text we render on top // Blending. This is required so that our text we render on top
// of our drawable properly blends into the bg. // of our drawable properly blends into the bg.
@ -535,7 +567,11 @@ fn initCellBgPipeline(device: objc.Object, library: objc.Object) !objc.Object {
} }
/// Initialize the image render pipeline for our shader library. /// Initialize the image render pipeline for our shader library.
fn initImagePipeline(device: objc.Object, library: objc.Object) !objc.Object { fn initImagePipeline(
device: objc.Object,
library: objc.Object,
pixel_format: mtl.MTLPixelFormat,
) !objc.Object {
// Get our vertex and fragment functions // Get our vertex and fragment functions
const func_vert = func_vert: { const func_vert = func_vert: {
const str = try macos.foundation.String.createWithBytes( const str = try macos.foundation.String.createWithBytes(
@ -619,8 +655,7 @@ fn initImagePipeline(device: objc.Object, library: objc.Object) !objc.Object {
.{@as(c_ulong, 0)}, .{@as(c_ulong, 0)},
); );
// Value is MTLPixelFormatBGRA8Unorm attachment.setProperty("pixelFormat", @intFromEnum(pixel_format));
attachment.setProperty("pixelFormat", @as(c_ulong, 80));
// Blending. This is required so that our text we render on top // Blending. This is required so that our text we render on top
// of our drawable properly blends into the bg. // of our drawable properly blends into the bg.

View File

@ -18,7 +18,11 @@ struct Uniforms {
float min_contrast; float min_contrast;
ushort2 cursor_pos; ushort2 cursor_pos;
uchar4 cursor_color; uchar4 cursor_color;
uchar4 bg_color;
bool cursor_wide; bool cursor_wide;
bool use_display_p3;
bool use_linear_blending;
bool use_experimental_linear_correction;
}; };
//------------------------------------------------------------------- //-------------------------------------------------------------------
@ -26,40 +30,82 @@ struct Uniforms {
//------------------------------------------------------------------- //-------------------------------------------------------------------
#pragma mark - Colors #pragma mark - Colors
// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef // D50-adapted sRGB to XYZ conversion matrix.
float luminance_component(float c) { // http://www.brucelindbloom.com/Eqn_RGB_XYZ_Matrix.html
if (c <= 0.03928f) { constant float3x3 sRGB_XYZ = transpose(float3x3(
return c / 12.92f; 0.4360747, 0.3850649, 0.1430804,
} else { 0.2225045, 0.7168786, 0.0606169,
return pow((c + 0.055f) / 1.055f, 2.4f); 0.0139322, 0.0971045, 0.7141733
} ));
// XYZ to Display P3 conversion matrix.
// http://endavid.com/index.php?entry=79
constant float3x3 XYZ_DP3 = transpose(float3x3(
2.40414768,-0.99010704,-0.39759019,
-0.84239098, 1.79905954, 0.01597023,
0.04838763,-0.09752546, 1.27393636
));
// By composing the two above matrices we get
// our sRGB to Display P3 conversion matrix.
constant float3x3 sRGB_DP3 = XYZ_DP3 * sRGB_XYZ;
// Converts a color in linear sRGB to linear Display P3
//
// TODO: The color matrix should probably be computed
// dynamically and passed as a uniform, rather
// than being hard coded above.
float3 srgb_to_display_p3(float3 srgb) {
return sRGB_DP3 * srgb;
} }
float relative_luminance(float3 color) { // Converts a color from sRGB gamma encoding to linear.
color.r = luminance_component(color.r); float4 linearize(float4 srgb) {
color.g = luminance_component(color.g); bool3 cutoff = srgb.rgb <= 0.04045;
color.b = luminance_component(color.b); float3 lower = srgb.rgb / 12.92;
float3 weights = float3(0.2126f, 0.7152f, 0.0722f); float3 higher = pow((srgb.rgb + 0.055) / 1.055, 2.4);
return dot(color, weights); srgb.rgb = mix(higher, lower, float3(cutoff));
return srgb;
}
// Converts a color from linear to sRGB gamma encoding.
float4 unlinearize(float4 linear) {
bool3 cutoff = linear.rgb <= 0.0031308;
float3 lower = linear.rgb * 12.92;
float3 higher = pow(linear.rgb, 1.0 / 2.4) * 1.055 - 0.055;
linear.rgb = mix(higher, lower, float3(cutoff));
return linear;
}
// Compute the luminance of the provided color.
//
// Takes colors in linear RGB space. If your colors are gamma
// encoded, linearize them before using them with this function.
float luminance(float3 color) {
return dot(color, float3(0.2126f, 0.7152f, 0.0722f));
} }
// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef // https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
//
// Takes colors in linear RGB space. If your colors are gamma
// encoded, linearize them before using them with this function.
float contrast_ratio(float3 color1, float3 color2) { float contrast_ratio(float3 color1, float3 color2) {
float l1 = relative_luminance(color1); float l1 = luminance(color1);
float l2 = relative_luminance(color2); float l2 = luminance(color2);
return (max(l1, l2) + 0.05f) / (min(l1, l2) + 0.05f); return (max(l1, l2) + 0.05f) / (min(l1, l2) + 0.05f);
} }
// Return the fg if the contrast ratio is greater than min, otherwise // Return the fg if the contrast ratio is greater than min, otherwise
// return a color that satisfies the contrast ratio. Currently, the color // return a color that satisfies the contrast ratio. Currently, the color
// is always white or black, whichever has the highest contrast ratio. // is always white or black, whichever has the highest contrast ratio.
//
// Takes colors in linear RGB space. If your colors are gamma
// encoded, linearize them before using them with this function.
float4 contrasted_color(float min, float4 fg, float4 bg) { float4 contrasted_color(float min, float4 fg, float4 bg) {
float3 fg_premult = fg.rgb * fg.a; float ratio = contrast_ratio(fg.rgb, bg.rgb);
float3 bg_premult = bg.rgb * bg.a;
float ratio = contrast_ratio(fg_premult, bg_premult);
if (ratio < min) { if (ratio < min) {
float white_ratio = contrast_ratio(float3(1.0f), bg_premult); float white_ratio = contrast_ratio(float3(1.0f), bg.rgb);
float black_ratio = contrast_ratio(float3(0.0f), bg_premult); float black_ratio = contrast_ratio(float3(0.0f), bg.rgb);
if (white_ratio > black_ratio) { if (white_ratio > black_ratio) {
return float4(1.0f); return float4(1.0f);
} else { } else {
@ -70,6 +116,62 @@ float4 contrasted_color(float min, float4 fg, float4 bg) {
return fg; return fg;
} }
// Load a 4 byte RGBA non-premultiplied color and linearize
// and convert it as necessary depending on the provided info.
//
// Returns a color in the Display P3 color space.
//
// If `display_p3` is true, then the provided color is assumed to
// already be in the Display P3 color space, otherwise it's treated
// as an sRGB color and is appropriately converted to Display P3.
//
// `linear` controls whether the returned color is linear or gamma encoded.
float4 load_color(
uchar4 in_color,
bool display_p3,
bool linear
) {
// 0 .. 255 -> 0.0 .. 1.0
float4 color = float4(in_color) / 255.0f;
// If our color is already in Display P3 and
// we aren't doing linear blending, then we
// already have the correct color here and
// can premultiply and return it.
if (display_p3 && !linear) {
color *= color.a;
return color;
}
// The color is in either the sRGB or Display P3 color space,
// so in either case, it's a color space which uses the sRGB
// transfer function, so we can use one function in order to
// linearize it in either case.
//
// Even if we aren't doing linear blending, the color
// needs to be in linear space to convert color spaces.
color = linearize(color);
// If we're *NOT* using display P3 colors, then we're dealing
// with an sRGB color, in which case we need to convert it in
// to the Display P3 color space, since our output is always
// Display P3.
if (!display_p3) {
color.rgb = srgb_to_display_p3(color.rgb);
}
// If we're not doing linear blending, then we need to
// unlinearize after doing the color space conversion.
if (!linear) {
color = unlinearize(color);
}
// Premultiply our color by its alpha.
color *= color.a;
return color;
}
//------------------------------------------------------------------- //-------------------------------------------------------------------
// Full Screen Vertex Shader // Full Screen Vertex Shader
//------------------------------------------------------------------- //-------------------------------------------------------------------
@ -112,25 +214,54 @@ vertex FullScreenVertexOut full_screen_vertex(
//------------------------------------------------------------------- //-------------------------------------------------------------------
#pragma mark - Cell BG Shader #pragma mark - Cell BG Shader
struct CellBgVertexOut {
float4 position [[position]];
float4 bg_color;
};
vertex CellBgVertexOut cell_bg_vertex(
uint vid [[vertex_id]],
constant Uniforms& uniforms [[buffer(1)]]
) {
CellBgVertexOut out;
float4 position;
position.x = (vid == 2) ? 3.0 : -1.0;
position.y = (vid == 0) ? -3.0 : 1.0;
position.zw = 1.0;
out.position = position;
// Convert the background color to Display P3
out.bg_color = load_color(
uniforms.bg_color,
uniforms.use_display_p3,
uniforms.use_linear_blending
);
return out;
}
fragment float4 cell_bg_fragment( fragment float4 cell_bg_fragment(
FullScreenVertexOut in [[stage_in]], CellBgVertexOut in [[stage_in]],
constant uchar4 *cells [[buffer(0)]], constant uchar4 *cells [[buffer(0)]],
constant Uniforms& uniforms [[buffer(1)]] constant Uniforms& uniforms [[buffer(1)]]
) { ) {
int2 grid_pos = int2(floor((in.position.xy - uniforms.grid_padding.wx) / uniforms.cell_size)); int2 grid_pos = int2(floor((in.position.xy - uniforms.grid_padding.wx) / uniforms.cell_size));
float4 bg = in.bg_color;
// Clamp x position, extends edge bg colors in to padding on sides. // Clamp x position, extends edge bg colors in to padding on sides.
if (grid_pos.x < 0) { if (grid_pos.x < 0) {
if (uniforms.padding_extend & EXTEND_LEFT) { if (uniforms.padding_extend & EXTEND_LEFT) {
grid_pos.x = 0; grid_pos.x = 0;
} else { } else {
return float4(0.0); return bg;
} }
} else if (grid_pos.x > uniforms.grid_size.x - 1) { } else if (grid_pos.x > uniforms.grid_size.x - 1) {
if (uniforms.padding_extend & EXTEND_RIGHT) { if (uniforms.padding_extend & EXTEND_RIGHT) {
grid_pos.x = uniforms.grid_size.x - 1; grid_pos.x = uniforms.grid_size.x - 1;
} else { } else {
return float4(0.0); return bg;
} }
} }
@ -139,18 +270,32 @@ fragment float4 cell_bg_fragment(
if (uniforms.padding_extend & EXTEND_UP) { if (uniforms.padding_extend & EXTEND_UP) {
grid_pos.y = 0; grid_pos.y = 0;
} else { } else {
return float4(0.0); return bg;
} }
} else if (grid_pos.y > uniforms.grid_size.y - 1) { } else if (grid_pos.y > uniforms.grid_size.y - 1) {
if (uniforms.padding_extend & EXTEND_DOWN) { if (uniforms.padding_extend & EXTEND_DOWN) {
grid_pos.y = uniforms.grid_size.y - 1; grid_pos.y = uniforms.grid_size.y - 1;
} else { } else {
return float4(0.0); return bg;
} }
} }
// Retrieve color for cell and return it. // We load the color for the cell, converting it appropriately, and return it.
return float4(cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x]) / 255.0; //
// TODO: We may want to blend the color with the background
// color, rather than purely replacing it, this needs
// some consideration about config options though.
//
// TODO: It might be a good idea to do a pass before this
// to convert all of the bg colors, so we don't waste
// a bunch of work converting the cell color in every
// fragment of each cell. It's not the most epxensive
// operation, but it is still wasted work.
return load_color(
cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x],
uniforms.use_display_p3,
uniforms.use_linear_blending
);
} }
//------------------------------------------------------------------- //-------------------------------------------------------------------
@ -222,7 +367,6 @@ vertex CellTextVertexOut cell_text_vertex(
CellTextVertexOut out; CellTextVertexOut out;
out.mode = in.mode; out.mode = in.mode;
out.color = float4(in.color) / 255.0f;
// === Grid Cell === // === Grid Cell ===
// +X // +X
@ -277,6 +421,14 @@ vertex CellTextVertexOut cell_text_vertex(
// be sampled with pixel coordinate mode. // be sampled with pixel coordinate mode.
out.tex_coord = float2(in.glyph_pos) + float2(in.glyph_size) * corner; out.tex_coord = float2(in.glyph_pos) + float2(in.glyph_size) * corner;
// Get our color. We always fetch a linearized version to
// make it easier to handle minimum contrast calculations.
out.color = load_color(
in.color,
uniforms.use_display_p3,
true
);
// If we have a minimum contrast, we need to check if we need to // If we have a minimum contrast, we need to check if we need to
// change the color of the text to ensure it has enough contrast // change the color of the text to ensure it has enough contrast
// with the background. // with the background.
@ -285,7 +437,13 @@ vertex CellTextVertexOut cell_text_vertex(
// and Powerline glyphs to be unaffected (else parts of the line would // and Powerline glyphs to be unaffected (else parts of the line would
// have different colors as some parts are displayed via background colors). // have different colors as some parts are displayed via background colors).
if (uniforms.min_contrast > 1.0f && in.mode == MODE_TEXT) { 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; // Get the BG color
float4 bg_color = load_color(
bg_colors[in.grid_pos.y * uniforms.grid_size.x + in.grid_pos.x],
uniforms.use_display_p3,
true
);
// Ensure our minimum contrast
out.color = contrasted_color(uniforms.min_contrast, out.color, bg_color); out.color = contrasted_color(uniforms.min_contrast, out.color, bg_color);
} }
@ -308,7 +466,8 @@ vertex CellTextVertexOut cell_text_vertex(
fragment float4 cell_text_fragment( fragment float4 cell_text_fragment(
CellTextVertexOut in [[stage_in]], CellTextVertexOut in [[stage_in]],
texture2d<float> textureGrayscale [[texture(0)]], texture2d<float> textureGrayscale [[texture(0)]],
texture2d<float> textureColor [[texture(1)]] texture2d<float> textureColor [[texture(1)]],
constant Uniforms& uniforms [[buffer(2)]]
) { ) {
constexpr sampler textureSampler( constexpr sampler textureSampler(
coord::pixel, coord::pixel,
@ -322,20 +481,63 @@ fragment float4 cell_text_fragment(
case MODE_TEXT_CONSTRAINED: case MODE_TEXT_CONSTRAINED:
case MODE_TEXT_POWERLINE: case MODE_TEXT_POWERLINE:
case MODE_TEXT: { case MODE_TEXT: {
// We premult the alpha to our whole color since our blend function // Our input color is always linear.
// uses One/OneMinusSourceAlpha to avoid blurry edges. float4 color = in.color;
// We first premult our given color.
float4 premult = float4(in.color.rgb * in.color.a, in.color.a);
// Then premult the texture color // If we're not doing linear blending, then we need to
// re-apply the gamma encoding to our color manually.
//
// We do it BEFORE premultiplying the alpha because
// we want to produce the effect of not linearizing
// it in the first place in order to match the look
// of software that never does this.
if (!uniforms.use_linear_blending) {
color = unlinearize(color);
}
// Fetch our alpha mask for this pixel.
float a = textureGrayscale.sample(textureSampler, in.tex_coord).r; float a = textureGrayscale.sample(textureSampler, in.tex_coord).r;
premult = premult * a;
return premult; // Experimental linear blending weight correction.
if (uniforms.use_experimental_linear_correction) {
float l = luminance(color.rgb);
// TODO: This is a dynamic dilation term that biases
// the alpha adjustment for small font sizes;
// it should be computed by dividing the font
// size in `pt`s by `13.0` and using that if
// it's less than `1.0`, but for now it's
// hard coded at 1.0, which has no effect.
float d = 13.0 / 13.0;
a += pow(a, d + d * l) - pow(a, d + 1.0 - d * l);
}
// Multiply our whole color by the alpha mask.
// Since we use premultiplied alpha, this is
// the correct way to apply the mask.
color *= a;
return color;
} }
case MODE_TEXT_COLOR: { case MODE_TEXT_COLOR: {
return textureColor.sample(textureSampler, in.tex_coord); // For now, we assume that color glyphs are
// already premultiplied Display P3 colors.
float4 color = textureColor.sample(textureSampler, in.tex_coord);
// If we aren't doing linear blending, we can return this right away.
if (!uniforms.use_linear_blending) {
return color;
}
// Otherwise we need to linearize the color. Since the alpha is
// premultiplied, we need to divide it out before linearizing.
color.rgb /= color.a;
color = linearize(color);
color.rgb *= color.a;
return color;
} }
} }
} }
@ -409,7 +611,8 @@ vertex ImageVertexOut image_vertex(
fragment float4 image_fragment( fragment float4 image_fragment(
ImageVertexOut in [[stage_in]], ImageVertexOut in [[stage_in]],
texture2d<uint> image [[texture(0)]] texture2d<uint> image [[texture(0)]],
constant Uniforms& uniforms [[buffer(1)]]
) { ) {
constexpr sampler textureSampler(address::clamp_to_edge, filter::linear); constexpr sampler textureSampler(address::clamp_to_edge, filter::linear);
@ -418,10 +621,12 @@ fragment float4 image_fragment(
// our texture to BGRA8Unorm. // our texture to BGRA8Unorm.
uint4 rgba = image.sample(textureSampler, in.tex_coord); uint4 rgba = image.sample(textureSampler, in.tex_coord);
// Convert to float4 and premultiply the alpha. We should also probably return load_color(
// premultiply the alpha in the texture. uchar4(rgba),
float4 result = float4(rgba) / 255.0f; // We assume all images are sRGB regardless of the configured colorspace
result.rgb *= result.a; // TODO: Maybe support wide gamut images?
return result; false,
uniforms.use_linear_blending
);
} }

View File

@ -520,6 +520,7 @@ pub fn clone(
assert(node.data.capacity.rows >= chunk.end - chunk.start); assert(node.data.capacity.rows >= chunk.end - chunk.start);
defer node.data.assertIntegrity(); defer node.data.assertIntegrity();
node.data.size.rows = chunk.end - chunk.start; node.data.size.rows = chunk.end - chunk.start;
node.data.size.cols = chunk.node.data.size.cols;
try node.data.cloneFrom( try node.data.cloneFrom(
&chunk.node.data, &chunk.node.data,
chunk.start, chunk.start,