Fix #2249: macOS converts the canvas from sRGB to the display's profile

The window's CAMetalLayer had no color space: wgpu 30.0.1 sets it to
nil for an sRGB surface each time it configures the surface, and Core
Animation does not color-match a layer without one. On a wide-gamut
display #FF0000 showed as the display's own red, (255, 0, 0) in a
capture where Preview shows the same sRGB red as (234, 51, 35).

A paint callback that draws nothing runs after egui configures the
surface and before the frame is presented. It sets sRGB on each
window's Metal layer that has no color space and leaves any other color
space as it is. wgpu's own fix (gfx-rs/wgpu#10286) is in no release yet.

Fixes #3161.
This commit is contained in:
dotCooCoo
2026-10-29 14:36:37 +07:00
committed by bflatastic
parent e31e04e545
commit f55cbf313c
5 changed files with 152 additions and 1 deletions
Generated
+3
View File
@@ -3282,6 +3282,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
]
[[package]]
@@ -6052,8 +6053,10 @@ dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-text",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
"pollster 1.0.1",
"raw-window-handle",
"rfd",
+5 -1
View File
@@ -58,7 +58,7 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_G
# with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.2", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "objc2-quartz-core", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
# Files opened from Finder and the Dock (src/open_documents.rs), pasted bitmaps (src/clipboard.rs) and the control channel's App Nap activity (src/control_server.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] }
block2 = "0.6"
@@ -70,6 +70,10 @@ raw-window-handle = "0.6"
# objc2-foundation.
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
# The windows' Metal layers and the sRGB color space they are tagged with (src/mac_color_space.rs).
# Zlib OR Apache-2.0 OR MIT, already in the build through wgpu with these features.
objc2-quartz-core = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "objc2-core-graphics", "CALayer", "CAMetalLayer"] }
objc2-core-graphics = { version = "0.3", default-features = false, features = ["std", "CGColorSpace"] }
# Waits for the answer of a file dialog shown as a sheet (src/main.rs, #867). MIT OR Apache-2.0,
# already in the build through eframe.
pollster = "1"
+3
View File
@@ -63,6 +63,9 @@ impl eframe::App for App {
let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar);
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
}
// macOS converts the frame from sRGB to the display's profile (#2249).
#[cfg(all(target_os = "macos", feature = "wgpu"))]
super::mac_color_space::tag(ui);
file_access::unconfined(|| self.app.ui(ui));
// Preferences are written when confirmed, not only at exit: a session ended any other way
// (the system shutting down, the process ended or crashing) used to lose them (#1747).
+139
View File
@@ -0,0 +1,139 @@
//! macOS: the windows' Metal layers are tagged sRGB, so macOS converts what VectorCraft draws to
//! the display's color profile (#2249, #3161).
//!
//! VectorCraft draws sRGB values. Apple's documentation of `CAMetalLayer.colorspace` says: "When a
//! color space is present, Core Animation performs any necessary color space transformations when
//! compositing this content. The default value is `nil`, indicating that the rendered content
//! isn’t color-matched"
//! (<https://developer.apple.com/documentation/quartzcore/cametallayer/colorspace>). Without a
//! color space, a wide-gamut display shows the sRGB values as its own, so `#FF0000` is drawn as
//! the display's most saturated red, brighter than the same color in an exported PNG.
//!
//! wgpu 30 sets the layer's color space to nil each time it configures the surface: at start, on
//! a resize and when the window moves to a display with another scale (gfx-rs/wgpu#10013). egui
//! configures the surface while it paints a frame, after [`eframe::App::ui`] has run, so [`tag`]
//! adds a paint callback that draws nothing. egui calls it after the configuration and before the
//! frame is presented, and it sets sRGB on every window's Metal layer that has no color space. A
//! layer that already has a color space keeps it.
//!
//! Reading a layer's sublayers has no safe API, hence the scoped `unsafe_code` allowance.
#![allow(unsafe_code)]
use eframe::egui_wgpu::{self, CallbackResources, CallbackTrait, wgpu};
use objc2::MainThreadMarker;
use objc2::rc::Retained;
use objc2::{DowncastTarget, Message};
use objc2_app_kit::NSApplication;
use objc2_core_graphics::{CGColorSpace, kCGColorSpaceSRGB};
use objc2_quartz_core::{CALayer, CAMetalLayer};
/// Adds the callback that tags the windows' Metal layers to the frame `ui` draws: a point in its
/// top-left corner, in the background layer, with nothing drawn.
pub fn tag(ui: &egui::Ui) {
let ctx = ui.ctx();
let rect = egui::Rect::from_min_size(ctx.content_rect().min, egui::vec2(1.0, 1.0));
ctx.layer_painter(egui::LayerId::background()).add(egui_wgpu::Callback::new_paint_callback(rect, TagLayers));
}
struct TagLayers;
impl CallbackTrait for TagLayers {
fn paint(&self, _: egui::PaintCallbackInfo, _: &mut wgpu::RenderPass<'static>, _: &CallbackResources) {
// AppKit's windows and views are read on the main thread only, where eframe paints.
let Some(mtm) = MainThreadMarker::new() else {
return;
};
for window in NSApplication::sharedApplication(mtm).windows() {
if let Some(root) = window.contentView().and_then(|view| view.layer()) {
for layer in metal_layers(&root) {
tag_srgb(&layer);
}
}
}
}
}
/// The Metal layers that draw a view whose layer is `root`: `root` itself, or the sublayers wgpu
/// adds to it when it is a plain layer.
fn metal_layers(root: &CALayer) -> Vec<Retained<CAMetalLayer>> {
if let Some(layer) = downcast(root) {
return vec![layer];
}
// SAFETY: the returned array is a copy; the layers in it are retained while it lives.
let Some(sublayers) = (unsafe { root.sublayers() }) else {
return Vec::new();
};
sublayers.iter().filter_map(|layer| downcast(&layer)).collect()
}
fn downcast<T: DowncastTarget>(layer: &CALayer) -> Option<Retained<T>> {
layer.retain().downcast::<T>().ok()
}
/// Sets sRGB as `layer`'s color space when it has none. Returns whether it was set.
fn tag_srgb(layer: &CAMetalLayer) -> bool {
if layer.colorspace().is_some() {
return false;
}
// SAFETY: a constant that CoreGraphics exports.
let Some(srgb) = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB })) else {
return false;
};
layer.setColorspace(Some(&srgb));
true
}
#[cfg(test)]
mod tests {
use objc2_core_foundation::CFString;
use objc2_core_graphics::kCGColorSpaceDisplayP3;
use super::*;
fn space_name(layer: &CAMetalLayer) -> Option<String> {
CGColorSpace::name(layer.colorspace().as_deref()).map(|name| name.to_string())
}
fn name(constant: &CFString) -> String {
constant.to_string()
}
/// A layer without a color space is tagged sRGB once; the second call leaves it as it is.
#[test]
fn an_untagged_layer_becomes_srgb() {
let layer = CAMetalLayer::new();
assert_eq!(space_name(&layer), None, "a new layer has no color space");
assert!(tag_srgb(&layer));
assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceSRGB })));
assert!(!tag_srgb(&layer));
assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceSRGB })));
}
/// A layer with a color space of its own keeps it.
#[test]
fn a_layer_with_a_color_space_keeps_it() {
let layer = CAMetalLayer::new();
let p3 = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceDisplayP3 })).unwrap();
layer.setColorspace(Some(&p3));
assert!(!tag_srgb(&layer));
assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceDisplayP3 })));
}
/// The Metal layer is found as the view's layer or as one of its sublayers, and only Metal
/// layers are returned.
#[test]
fn metal_layers_are_found_under_the_view_layer() {
let metal = CAMetalLayer::new();
assert_eq!(metal_layers(&metal), vec![metal.clone()]);
let root = CALayer::new();
assert!(metal_layers(&root).is_empty());
root.addSublayer(&CALayer::new());
root.addSublayer(&metal);
assert_eq!(metal_layers(&root), vec![metal.clone()]);
for layer in metal_layers(&root) {
tag_srgb(&layer);
}
assert_eq!(space_name(&metal), Some(name(unsafe { kCGColorSpaceSRGB })));
}
}
+2
View File
@@ -26,6 +26,8 @@ mod gpu;
#[cfg(target_os = "linux")]
mod linux_stylus;
mod logging;
#[cfg(all(target_os = "macos", feature = "wgpu"))]
mod mac_color_space;
#[cfg(target_os = "macos")]
mod mac_fonts;
#[cfg(target_os = "macos")]