From 41dac8f33e12d9f657a451223511211241f485b1 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sat, 29 Aug 2026 00:24:00 +0800 Subject: [PATCH] platform: declare the window content colorspace to the OS WindowContentColorspace (primaries x transfer) + set_content_colorspace: Wayland rebuilds the color-management-v1 image description per spec (P3/BT.2020 primaries, PQ/HLG transfer with luminances), macOS tags the Metal layer with the matching CGColorSpace (incl. BT.2100 PQ/HLG), Windows records it (ACM only honors sRGB). Also: warn when the wgpu surface fallback would pick an sRGB-suffixed format (double encoding), share the surface-params uniform across 256 slots (multiple viewers per frame), and cfg-gate the viewer's GPU-texture source for macOS/Windows. --- crates/gpui/src/platform.rs | 86 +++++++++++ crates/gpui/src/window.rs | 28 +++- crates/gpui_linux/src/linux/wayland/client.rs | 51 +++++++ crates/gpui_linux/src/linux/wayland/window.rs | 139 +++++++++++++++++- crates/gpui_macos/src/display_colorspace.rs | 104 +++++++++++-- crates/gpui_macos/src/metal_renderer.rs | 67 +++++++-- crates/gpui_macos/src/window.rs | 56 +++++++ crates/gpui_wgpu/src/wgpu_renderer.rs | 68 +++++++-- crates/gpui_widgets/src/viewer/mod.rs | 7 + crates/gpui_windows/src/directx_renderer.rs | 30 ++++ crates/gpui_windows/src/window.rs | 15 ++ 11 files changed, 607 insertions(+), 44 deletions(-) diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 944912e0a1..cb92bb8494 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -743,6 +743,27 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn set_client_inset(&self, _inset: Pixels) {} fn gpu_specs(&self) -> Option; + /// Declare who maps this window's pixels to the physical display (the + /// single-mapping rule of color management). `OsManaged` tags the + /// content as a defined colorimetric space (sRGB) so the OS performs + /// the display mapping; `SelfManaged` tags the layer with the display's + /// own color space so the OS passes the app's already-mapped pixels + /// through. Platforms without a per-layer declaration (Windows, X11) + /// ignore it; Wayland declares the content space through + /// color-management-v1 at the surface level, not here. + fn set_layer_color_management(&mut self, _mode: LayerColorManagement) {} + + /// Declare the colorimetric space this window's content is encoded in + /// (the "content is what" half of the color-management declaration; see + /// [`WindowContentColorspace`]). Combined with + /// [`Self::set_layer_color_management`] the platform knows both what the + /// content is and who maps it, so the one display mapping is fully + /// determined. macOS retags the Metal layer; Wayland rebuilds the + /// color-management-v1 image description; platforms without a per-layer + /// declaration (Windows, X11) record it without acting on it — the OS + /// maps sRGB-declared content by default. + fn set_content_colorspace(&mut self, _colorspace: WindowContentColorspace) {} + /// Returns the GPU context for this window's renderer. /// The returned `Box` contains `(Arc, Arc)`. #[cfg(any(target_os = "linux", target_os = "freebsd"))] @@ -1711,6 +1732,71 @@ pub enum WindowAppearance { VibrantDark, } +/// Who performs the final color mapping from the window's rendered pixels +/// to the physical display, declared to the platform layer so the mapping +/// happens exactly once (the single-mapping rule of color management). +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum LayerColorManagement { + /// The OS maps the window's content to the display. The content is a + /// defined colorimetric space (sRGB unless the platform declares + /// otherwise), so the layer is tagged accordingly and the OS performs + /// the one and only display mapping. + #[default] + OsManaged, + + /// The application has already mapped its content to the display (it + /// applied the display ICC transform itself). The layer is tagged with + /// the display's own color space so the OS passes the pixels through + /// instead of correcting them a second time. + SelfManaged, +} + +/// The colorimetric space the window's content is encoded in, declared to +/// the platform layer alongside [`LayerColorManagement`] (who maps) so the +/// one display mapping is fully determined: the content space is named +/// here, the display space is known to the OS, and whichever side maps +/// does so exactly once. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub struct WindowContentColorspace { + /// The primaries (chromaticities) the content is encoded with. + pub primaries: ContentPrimaries, + /// The transfer function (encoding curve) the content is encoded with. + pub transfer: ContentTransfer, +} + +/// The primaries (chromaticity coordinates) the window's content is +/// encoded with. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum ContentPrimaries { + /// sRGB primaries — identical to BT.709, the classic SDR + /// broadcast/video primaries. + #[default] + Srgb, + /// Display P3 primaries (the wider, DCI-P3-derived space of Apple and + /// modern displays). + DisplayP3, + /// BT.2020 primaries (the ultra-wide space of UHD/HDR video). + Bt2020, +} + +/// The transfer function (encoding curve) the window's content is encoded +/// with. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum ContentTransfer { + /// The sRGB transfer curve (the standard SDR curve of displays and + /// web/video content). + #[default] + Srgb, + /// Pure gamma 2.2 (the classic video SDR curve; near-identical to sRGB + /// in practice). + Gamma22, + /// SMPTE ST 2084 (PQ), the perceptually-quantized HDR curve (HDR10). + Pq, + /// Hybrid Log-Gamma, the broadcast HDR curve that carries SDR + /// compatibility in its lower range. + Hlg, +} + /// The appearance of the background of the window itself, when there is /// no content or the content is transparent. #[derive(Copy, Clone, Debug, Default, PartialEq)] diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 710cbc7462..49f4dbb974 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -7,7 +7,8 @@ use crate::{ DispatchActionListener, DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity, EntityId, EventEmitter, FileDropEvent, Filter, FilterBoundary, FontId, Global, GlobalElementId, GlyphId, GpuSpecs, Hsla, InputHandler, IsZero, KeyBinding, KeyContext, KeyDownEvent, KeyEvent, - Keystroke, KeystrokeEvent, LayoutId, Lerp, LineLayoutIndex, Modifiers, ModifiersChangedEvent, + Keystroke, KeystrokeEvent, LayerColorManagement, LayoutId, Lerp, LineLayoutIndex, Modifiers, + ModifiersChangedEvent, MonochromeSprite, MouseButton, MouseEvent, MouseMoveEvent, MouseUpEvent, Path, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, Priority, PromptButton, PromptLevel, Quad, Render, RenderGlyphParams, @@ -17,8 +18,9 @@ use crate::{ SystemWindowTab, SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, TransformationMatrix, Transition, TransitionState, Underline, UnderlineStyle, WindowAppearance, - WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, WindowOptions, - WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, transparent_black, + WindowBackgroundAppearance, WindowBounds, WindowContentColorspace, WindowControls, + WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, + size, transparent_black, }; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, FxHashSet}; @@ -5546,6 +5548,26 @@ impl Window { self.platform_window.gpu_device_lost() } + /// Declare who maps this window's pixels to the physical display (the + /// single-mapping rule of color management). Call this when the app's + /// display color-management mode changes; on macOS it retags the Metal + /// layer so ColorSync either maps the content (OS-managed) or passes + /// the app's already-mapped pixels through (self-managed). Other + /// platforms treat it as a no-op or surface-level declaration. + pub fn set_layer_color_management(&mut self, mode: LayerColorManagement) { + self.platform_window.set_layer_color_management(mode); + } + + /// Declare the colorimetric space this window's content is encoded in + /// (see [`WindowContentColorspace`]) — the "content is what" half of the + /// color-management declaration, matched with the "who maps" half in + /// [`Self::set_layer_color_management`]. Call this when the app's output + /// colorspace changes; platforms with a per-layer declaration (macOS, + /// Wayland) act on it immediately, others record it for later. + pub fn set_content_colorspace(&mut self, colorspace: WindowContentColorspace) { + self.platform_window.set_content_colorspace(colorspace); + } + /// Perform titlebar double-click action. /// This is macOS specific. pub fn titlebar_double_click(&self) { diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 785903cd5d..4f76b4d395 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -67,6 +67,10 @@ use wayland_protocols::{ wp::fractional_scale::v1::client::{wp_fractional_scale_manager_v1, wp_fractional_scale_v1}, xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1, }; +use wayland_protocols::wp::color_management::v1::client::{ + wp_color_management_surface_v1, wp_color_manager_v1, wp_image_description_creator_params_v1, + wp_image_description_v1, +}; use wayland_protocols_plasma::blur::client::{org_kde_kwin_blur, org_kde_kwin_blur_manager}; use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1}; use xkbcommon::xkb::ffi::XKB_KEYMAP_FORMAT_TEXT_V1; @@ -132,6 +136,11 @@ pub struct Globals { pub gesture_manager: Option, pub dialog: Option, pub system_bell: Option, + /// color-management-v1: declares each surface's content colorspace to + /// the compositor (the OS side of the single-mapping rule on Wayland). + /// `None` on compositors without the extension — they assume sRGB, + /// which is exactly what the app presents, so behavior is unchanged. + pub color_manager: Option, pub executor: ForegroundExecutor, } @@ -174,6 +183,10 @@ impl Globals { gesture_manager: globals.bind(&qh, 1..=3, ()).ok(), dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(), system_bell: globals.bind(&qh, 1..=1, ()).ok(), + // The compositor advertises the version it implements; negotiate + // up to 2 (the sRGB declaration only uses requests available at + // version 1, so a v1 compositor works identically). + color_manager: globals.bind(&qh, 1..=2, ()).ok(), executor, qh, } @@ -1176,6 +1189,14 @@ delegate_noop!(WaylandClientStatePtr: ignore zwp_text_input_manager_v3::ZwpTextI delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur::OrgKdeKwinBlur); delegate_noop!(WaylandClientStatePtr: ignore wp_viewporter::WpViewporter); delegate_noop!(WaylandClientStatePtr: ignore wp_viewport::WpViewport); +// color-management-v1: the app only ever SETS image descriptions (the +// surface content colorspace declaration) — the manager's capability +// events and the surface/creator lifecycle events need no handling, so +// ignore them. The image-description `ready`/`failed` events DO get a real +// Dispatch below. +delegate_noop!(WaylandClientStatePtr: ignore wp_color_manager_v1::WpColorManagerV1); +delegate_noop!(WaylandClientStatePtr: ignore wp_color_management_surface_v1::WpColorManagementSurfaceV1); +delegate_noop!(WaylandClientStatePtr: ignore wp_image_description_creator_params_v1::WpImageDescriptionCreatorParamsV1); impl Dispatch for WaylandClientStatePtr { fn event( @@ -2307,6 +2328,36 @@ impl Dispatch for Wayland } } +impl Dispatch for WaylandClientStatePtr { + fn event( + this: &mut Self, + image_description: &wp_image_description_v1::WpImageDescriptionV1, + event: ::Event, + surface_id: &ObjectId, + _: &Connection, + _: &QueueHandle, + ) { + use wp_image_description_v1::Event; + match event { + // The description is usable now — attach it to the surface. + // (Interface v1 sends `Ready`, v2+ sends `Ready2`.) + Event::Ready { .. } | Event::Ready2 { .. } => { + let client = this.get_client(); + let mut state = client.borrow_mut(); + let Some(window) = get_window(&mut state, surface_id) else { + return; + }; + drop(state); + window.apply_color_description(image_description.clone()); + } + Event::Failed { msg, .. } => { + log::warn!("wayland color-management image description rejected: {msg}"); + } + _ => {} + } + } +} + impl Dispatch for WaylandClientStatePtr { diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index b381890317..6157dc2bfc 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -11,6 +11,7 @@ use futures::channel::oneshot::Receiver; use raw_window_handle as rwh; use wayland_backend::client::ObjectId; +use wayland_client::QueueHandle; use wayland_client::WEnum; use wayland_client::{ Proxy, @@ -24,18 +25,21 @@ use wayland_protocols::{ wp::fractional_scale::v1::client::wp_fractional_scale_v1, xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1, }; +use wayland_protocols::wp::color_management::v1::client::{ + wp_color_management_surface_v1, wp_color_manager_v1, wp_image_description_v1, +}; use wayland_protocols_plasma::blur::client::org_kde_kwin_blur; use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1; use crate::linux::wayland::{display::WaylandDisplay, serial::SerialKind}; use crate::linux::{Globals, Output, WaylandClientStatePtr, get_window}; use gpui::{ - AnyWindowHandle, Bounds, Capslock, Decorations, DevicePixels, GpuSpecs, Modifiers, Pixels, - PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, - PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size, Tiling, - WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, - WindowDecorations, WindowKind, WindowParams, layer_shell::LayerShellNotSupportedError, px, - size, + AnyWindowHandle, Bounds, Capslock, ContentPrimaries, ContentTransfer, Decorations, + DevicePixels, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, + PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, + ResizeEdge, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, + WindowContentColorspace, WindowControlArea, WindowControls, WindowDecorations, WindowKind, + WindowParams, layer_shell::LayerShellNotSupportedError, px, size, }; use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig, wgpu}; @@ -99,6 +103,19 @@ pub struct WaylandWindowState { appearance: WindowAppearance, blur: Option, viewport: Option, + /// color-management-v1 handle for this surface (the OS side of the + /// single-mapping rule); `None` when the compositor lacks the extension. + color_surface: Option, + /// The image description while it waits for the compositor's `ready` + /// event (illegal to use before then); dropped once applied. Rebuilt + /// with a new description when the content colorspace changes. + pending_color_description: Option, + /// The colorimetric space this surface's content is encoded in, as + /// declared to the compositor via color-management-v1 (the "content is + /// what" half of the single-mapping rule; the compositor maps it to the + /// display — the "who maps" half is always the OS on Wayland). Kept so + /// a later `set_content_colorspace` can rebuild the description. + colorspace: WindowContentColorspace, outputs: HashMap, display: Option<(ObjectId, Output)>, globals: Globals, @@ -325,6 +342,49 @@ pub struct WaylandWindowStatePtr { callbacks: Rc>, } +/// Creates a parametric color-management-v1 image description declaring +/// `colorspace` for the surface whose id is `surface_id` (the description +/// is looked up by that id in the client's dispatch, so the window's +/// surface id is used as the object data, mirroring window creation). +/// +/// The description is not usable until the compositor replies with its +/// `ready`/`ready2` event — the caller keeps it pending (or queues it for +/// `apply_color_description`) until then. +fn create_color_description( + color_manager: &wp_color_manager_v1::WpColorManagerV1, + qh: &QueueHandle, + surface_id: ObjectId, + colorspace: WindowContentColorspace, +) -> wp_image_description_v1::WpImageDescriptionV1 { + let params = color_manager.create_parametric_creator(qh, ()); + let primaries = match colorspace.primaries { + ContentPrimaries::Srgb => wp_color_manager_v1::Primaries::Srgb, + ContentPrimaries::DisplayP3 => wp_color_manager_v1::Primaries::DisplayP3, + ContentPrimaries::Bt2020 => wp_color_manager_v1::Primaries::Bt2020, + }; + params.set_primaries_named(primaries); + // Protocol v1 has no sRGB transfer-function value a client can use + // (`srgb` was only added in v2, deprecated right after): for sRGB-class + // SDR content the compositor-side recommendation is to declare gamma + // 2.2, which is near-identical in practice — so both `Srgb` and + // `Gamma22` content map to it. + let transfer = match colorspace.transfer { + ContentTransfer::Srgb | ContentTransfer::Gamma22 => { + wp_color_manager_v1::TransferFunction::Gamma22 + } + ContentTransfer::Pq => wp_color_manager_v1::TransferFunction::St2084Pq, + ContentTransfer::Hlg => wp_color_manager_v1::TransferFunction::Hlg, + }; + params.set_tf_named(transfer); + // The protocol's default luminances are SDR (0.2–80 cd/m²); an HDR + // transfer function implies a wider volume, so pin it explicitly: PQ + // 1.0 peaks at 10000 cd/m² with an 80 cd/m² reference white. + if matches!(colorspace.transfer, ContentTransfer::Pq | ContentTransfer::Hlg) { + params.set_luminances(0, 10000, 80); + } + params.create(qh, surface_id) +} + impl WaylandWindowState { pub(crate) fn new( handle: AnyWindowHandle, @@ -373,13 +433,35 @@ impl WaylandWindowState { xdg_state.toplevel.set_title(title.to_string()); } // Set max window size based on the GPU's maximum texture dimension. - // This prevents the window from being resized larger than what the GPU can render. + // This prevents the window from being resized larger than the GPU can render. let max_texture_size = renderer.max_texture_size() as i32; xdg_state .toplevel .set_max_size(max_texture_size, max_texture_size); } + // color-management-v1: declare the surface content's colorspace so a + // color-managed compositor maps it to the display (the OS side of + // the single-mapping rule). The image description object only + // becomes usable on its `ready` event — handled in + // `apply_color_description` — so keep both objects on the state. + // `colorspace` defaults to sRGB content (Srgb + Srgb), which is + // what the app presented before this declaration existed. + let colorspace = WindowContentColorspace::default(); + let (color_surface, pending_color_description) = match globals.color_manager.as_ref() { + Some(color_manager) => { + let cm_surface = color_manager.get_surface(&surface, &globals.qh, ()); + let desc = create_color_description( + color_manager, + &globals.qh, + surface.id(), + colorspace, + ); + (Some(cm_surface), Some(desc)) + } + None => (None, None), + }; + Ok(Self { surface_state, acknowledged_first_configure: false, @@ -389,6 +471,9 @@ impl WaylandWindowState { app_id: None, blur: None, viewport, + color_surface, + pending_color_description, + colorspace, globals, outputs: HashMap::default(), display: None, @@ -583,6 +668,25 @@ impl WaylandWindowStatePtr { self.state.borrow().surface.clone() } + /// Applies a ready image description to this window's surface + /// (color-management-v1), replacing whatever description was applied + /// before. Called from the image-description `ready` dispatch for both + /// the initial declaration and rebuilds after a colorspace change; a + /// no-op when the compositor lacks the extension. The pending hold on + /// the description is released — `set_image_description` has copy + /// semantics, so the object may be dropped right after. + pub fn apply_color_description(&self, desc: wp_image_description_v1::WpImageDescriptionV1) { + let mut state = self.state.borrow_mut(); + state.pending_color_description = None; + let Some(color_surface) = state.color_surface.as_ref() else { + return; + }; + color_surface.set_image_description(&desc, wp_color_manager_v1::RenderIntent::Perceptual); + let surface = state.surface.clone(); + drop(state); + surface.commit(); + } + pub fn toplevel(&self) -> Option { self.state.borrow().surface_state.toplevel().cloned() } @@ -1309,6 +1413,27 @@ impl PlatformWindow for WaylandWindow { update_window(state); } + fn set_content_colorspace(&mut self, colorspace: WindowContentColorspace) { + let mut state = self.borrow_mut(); + state.colorspace = colorspace; + // Rebuild the color-management-v1 image description for the new + // space; the compositor replies with `ready`, which the client + // dispatch routes back into `apply_color_description` (the pending + // slot replaced here is the same one that call consumes). No-op + // when the compositor lacks the extension — it assumes sRGB, which + // the recorded value only overrides on compositors that care. + let Some(color_manager) = state.globals.color_manager.as_ref() else { + return; + }; + let desc = create_color_description( + color_manager, + &state.globals.qh, + state.surface.id(), + colorspace, + ); + state.pending_color_description = Some(desc); + } + fn background_appearance(&self) -> WindowBackgroundAppearance { self.borrow().background_appearance } diff --git a/crates/gpui_macos/src/display_colorspace.rs b/crates/gpui_macos/src/display_colorspace.rs index 45ff69ebd1..4c74486e36 100644 --- a/crates/gpui_macos/src/display_colorspace.rs +++ b/crates/gpui_macos/src/display_colorspace.rs @@ -1,10 +1,10 @@ // GPUI macOS platform - GPUI is licensed under the Apache License, Version 2.0 // (see the gpui submodule's license). -//! The main display's colorspace as a raw `CGColorSpaceRef`, for the -//! color-management coordination in `metal_renderer` (when the app -//! self-manages the display transform, the CAMetalLayer is tagged with the -//! display's colorspace so ColorSync passes the pixels through). +//! The colorspace tagging of the window's Metal layer, for the +//! color-management coordination in `metal_renderer` (the single-mapping +//! rule: whoever maps the pixels to the display — the OS or the app — the +//! layer is tagged so the other side does not map them again). //! //! This lives in its own module because the `metal_renderer` build script //! scans `metal_renderer.rs` for FFI declarations and forwards them into @@ -12,10 +12,20 @@ use std::ffi::c_void; +use gpui::{ContentPrimaries, ContentTransfer, WindowContentColorspace}; + #[link(name = "CoreGraphics", kind = "framework")] unsafe extern "C" { fn CGMainDisplayID() -> u32; fn CGDisplayCopyColorSpace(display: u32) -> *mut c_void; + fn CGColorSpaceCreateWithName(name: *const c_void) -> *mut c_void; + static kCGColorSpaceSRGB: *const c_void; + static kCGColorSpaceDisplayP3: *const c_void; + static kCGColorSpaceITUR_2020: *const c_void; + // The BT.2100 constants exist only on macOS 10.15+ (the deployment + // target); on older systems they resolve to nil and creation fails. + static kCGColorSpaceITUR_2100_PQ: *const c_void; + static kCGColorSpaceITUR_2100_HLG: *const c_void; } #[link(name = "CoreFoundation", kind = "framework")] @@ -23,13 +33,89 @@ unsafe extern "C" { fn CFRelease(obj: *const c_void); } -/// The main display's colorspace as a raw pointer (caller's -/// responsibility to keep it alive for the msg_send call; released here -/// after the layer call returns — the layer retains it). `None` when the -/// display has no colorspace (headless). +/// Run `f` with the colorspace of `display_id` as a raw `CGColorSpaceRef` +/// (caller's responsibility to keep it alive for the duration of the call; +/// released here afterwards — receivers such as the Metal layer retain it). +/// Returns whether `f` ran; `false` when the display has no colorspace +/// (headless), in which case the caller keeps whatever tag the layer had. +pub fn with_display_colorspace(display_id: u32, f: impl FnOnce(*mut c_void)) -> bool { + unsafe { + let space = CGDisplayCopyColorSpace(display_id); + if space.is_null() { + return false; + } + f(space); + CFRelease(space); + true + } +} + +/// The main display's colorspace (kept for callers without a specific +/// screen context). pub fn with_main_display_colorspace(f: impl FnOnce(*mut c_void)) { unsafe { - let space = CGDisplayCopyColorSpace(CGMainDisplayID()); + let display = CGMainDisplayID(); + with_display_colorspace(display, f); + } +} + +/// The main display's `CGDirectDisplayID` (fallback when a window has no +/// screen to ask for its own). +pub fn main_display_id() -> u32 { + unsafe { CGMainDisplayID() } +} + +/// Run `f` with the sRGB colorspace as a raw `CGColorSpaceRef` (the +/// declaration that the layer's content is colorimetric sRGB, so +/// ColorSync performs the display mapping). Skips `f` when the colorspace +/// cannot be created. +pub fn with_srgb_colorspace(f: impl FnOnce(*mut c_void)) { + unsafe { + let space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); + if space.is_null() { + return; + } + f(space); + CFRelease(space); + } +} + +/// Run `f` with the `CGColorSpaceRef` matching the window's declared +/// content colorspace — the tag says what the layer's pixels *are*, so +/// ColorSync performs the one mapping to the display (the single-mapping +/// rule in this module's docs). Falls back toward sRGB and skips `f` only +/// if nothing could be created. +pub fn with_content_colorspace(spec: WindowContentColorspace, f: impl FnOnce(*mut c_void)) { + unsafe { + let name = match spec.transfer { + ContentTransfer::Srgb | ContentTransfer::Gamma22 => match spec.primaries { + ContentPrimaries::Srgb => kCGColorSpaceSRGB, + ContentPrimaries::DisplayP3 => kCGColorSpaceDisplayP3, + ContentPrimaries::Bt2020 => kCGColorSpaceITUR_2020, + }, + // The ITU-R BT.2100 constants exist only on macOS 10.15+. + ContentTransfer::Pq => kCGColorSpaceITUR_2100_PQ, + ContentTransfer::Hlg => kCGColorSpaceITUR_2100_HLG, + }; + let space = CGColorSpaceCreateWithName(name); + if !space.is_null() { + f(space); + CFRelease(space); + return; + } + // Creation failed (a BT.2100 declaration on macOS < 10.15, or an + // unusual named-space miss). Fall back — HLG to PQ, everything else + // to sRGB — so the layer keeps a valid colorspace declaration. + log::warn!( + "no CGColorSpace for content colorspace {:?}, falling back", + spec + ); + let fallback = if matches!(spec.transfer, ContentTransfer::Hlg) { + kCGColorSpaceITUR_2100_PQ + } else { + kCGColorSpaceSRGB + }; + let space = CGColorSpaceCreateWithName(fallback); if space.is_null() { return; } diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs index 59ff75da9c..ea411b8ba2 100644 --- a/crates/gpui_macos/src/metal_renderer.rs +++ b/crates/gpui_macos/src/metal_renderer.rs @@ -251,23 +251,66 @@ impl MetalRenderer { ]; } - // Color management coordination: when the app self-manages the - // display transform (OAK_MACOS_LAYER_COLORSPACE=display, set at - // startup when display-ICC color management is active), tag the - // layer with the DISPLAY's colorspace so ColorSync's mapping - // becomes a pass-through — otherwise the OS would re-correct our - // already-corrected pixels (double correction). - if std::env::var_os("OAK_MACOS_LAYER_COLORSPACE").as_deref() - == Some(std::ffi::OsStr::new("display")) - { - crate::display_colorspace::with_main_display_colorspace(|space| unsafe { + // Color management coordination (the single-mapping rule): by default + // the content is colorimetric sRGB and ColorSync maps it to the + // display, so tag the layer with the declared content colorspace + // explicitly rather than relying on the implicit default. When the + // app self-manages the display transform it re-tags the layer with + // the display's own colorspace via `set_layer_color_management`, + // making ColorSync's mapping a pass-through (otherwise the OS would + // re-correct the already-corrected pixels — a double correction). + crate::display_colorspace::with_content_colorspace( + gpui::WindowContentColorspace::default(), + |space| unsafe { let _: () = msg_send![&*layer, setColorspace: space]; - }); - } + }, + ); Self::new_internal(device, Some(layer), !transparent, instance_buffer_pool) } + /// Tag the window's Metal layer for who maps the pixels to the display. + /// + /// `OsManaged` tags the layer with `content_colorspace` — the declaration + /// of what the pixels are, so ColorSync performs the one display mapping. + /// `SelfManaged` tags the layer with `display_id`'s colorspace (the + /// content declaration is then informational: the app already mapped the + /// pixels) so ColorSync passes them through; a display with no colorspace + /// (headless) leaves the current tag and logs, since a stale pass-through + /// tag would make the OS re-map the app's already-mapped pixels. No-op + /// for the headless renderer (no layer). + pub fn set_layer_color_management( + &self, + mode: gpui::LayerColorManagement, + display_id: u32, + content_colorspace: gpui::WindowContentColorspace, + ) { + let Some(layer) = self.layer.as_ref() else { + return; + }; + match mode { + gpui::LayerColorManagement::OsManaged => { + crate::display_colorspace::with_content_colorspace(content_colorspace, |space| { + unsafe { + let _: () = msg_send![&*layer, setColorspace: space]; + } + }); + } + gpui::LayerColorManagement::SelfManaged => { + let tagged = + crate::display_colorspace::with_display_colorspace(display_id, |space| unsafe { + let _: () = msg_send![&*layer, setColorspace: space]; + }); + if !tagged { + log::error!( + "display {display_id} has no colorspace; leaving the layer's previous \ + tag — ColorSync may re-map the app's already-mapped pixels" + ); + } + } + } + } + /// Creates a new headless MetalRenderer for offscreen rendering without a window. /// /// This renderer can render scenes to images without requiring a CAMetalLayer, diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index feba72ac38..1d0af582b7 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -473,6 +473,12 @@ struct MacWindowState { cursor_visible: Arc, display_link: Option, renderer: renderer::Renderer, + /// Who maps this window's pixels to the display (retagged on screen + /// changes so the self-managed pass-through follows the window). + layer_color_management: gpui::LayerColorManagement, + /// The colorspace the window's content is declared to be in (the tag + /// ColorSync maps from when the OS manages the display transform). + content_colorspace: gpui::WindowContentColorspace, request_frame_callback: Option>, event_callback: Option gpui::DispatchEventResult>>, activate_callback: Option>, @@ -800,6 +806,8 @@ impl MacWindow { bounds.size.map(|pixels| pixels.as_f32()), false, ), + layer_color_management: gpui::LayerColorManagement::default(), + content_colorspace: gpui::WindowContentColorspace::default(), request_frame_callback: None, event_callback: None, activate_callback: None, @@ -1647,6 +1655,27 @@ impl PlatformWindow for MacWindow { None } + fn set_layer_color_management(&mut self, mode: gpui::LayerColorManagement) { + let mut lock = self.0.lock(); + lock.layer_color_management = mode; + let display_id = current_display_id(lock.native_window); + lock.renderer + .set_layer_color_management(mode, display_id, lock.content_colorspace); + } + + fn set_content_colorspace(&mut self, colorspace: gpui::WindowContentColorspace) { + let mut lock = self.0.lock(); + lock.content_colorspace = colorspace; + // The layer tag declares the content's colorspace only when the OS + // performs the display mapping; when the app self-manages it, the tag + // is the display's colorspace regardless of the content declaration. + if lock.layer_color_management == gpui::LayerColorManagement::OsManaged { + let display_id = current_display_id(lock.native_window); + lock.renderer + .set_layer_color_management(lock.layer_color_management, display_id, colorspace); + } + } + fn update_ime_position(&self, _bounds: Bounds) { let executor = self.0.lock().foreground_executor.clone(); executor @@ -2363,10 +2392,37 @@ extern "C" fn window_did_change_screen(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; let mut lock = window_state.as_ref().lock(); lock.start_display_link(); + // The self-managed pass-through tag carries the display's colorspace; + // re-tag so it follows the window to the new screen (OS-managed is + // sRGB everywhere, but retagging is cheap and keeps one code path). + let mode = lock.layer_color_management; + let display_id = current_display_id(lock.native_window); + lock.renderer + .set_layer_color_management(mode, display_id, lock.content_colorspace); drop(lock); update_window_scale_factor(&window_state); } +/// The `CGDirectDisplayID` of the screen the window is currently on, +/// read from the screen's device description (`NSScreenNumber`). Falls +/// back to the main display when the window has no screen (minimized) or +/// the description lacks the number. +fn current_display_id(native_window: id) -> CGDirectDisplayID { + unsafe { + let screen: id = msg_send![native_window, screen]; + if !screen.is_null() { + let device_description: id = msg_send![screen, deviceDescription]; + let key: id = ns_string("NSScreenNumber"); + let screen_number: id = msg_send![device_description, objectForKey: key]; + if !screen_number.is_null() { + let number: NSUInteger = msg_send![screen_number, unsignedIntegerValue]; + return number as CGDirectDisplayID; + } + } + } + crate::display_colorspace::main_display_id() +} + extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; let lock = window_state.lock(); diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index 2c3b4b0990..cd63543536 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -139,7 +139,9 @@ fn preferred_surface_formats() -> &'static [wgpu::TextureFormat] { /// Picks the swapchain format for a surface: the first preferred format /// the surface supports, falling back to the first non-sRGB format the -/// surface offers, then to whatever it offers first. +/// surface offers, then to whatever it offers first (an sRGB format there +/// is a last resort — the surface format is also the shader output format, +/// so the hardware would encode the shader's already-sRGB values again). fn select_surface_format( preferred: &[wgpu::TextureFormat], supported: &[wgpu::TextureFormat], @@ -149,7 +151,16 @@ fn select_surface_format( .find(|f| supported.contains(f)) .copied() .or_else(|| supported.iter().find(|f| !f.is_srgb()).copied()) - .or_else(|| supported.first().copied()) + .or_else(|| { + let format = supported.first().copied(); + if let Some(format) = format.filter(|f| f.is_srgb()) { + log::warn!( + "surface offers only the sRGB format {format:?}; the format linearizes \ + shader output, so sRGB-encoded content is encoded once more (double encoding)" + ); + } + format + }) } pub struct WgpuSurfaceConfig { @@ -205,8 +216,12 @@ struct WgpuResources { bind_group_layouts: WgpuBindGroupLayouts, atlas_sampler: wgpu::Sampler, surface_sampler: wgpu::Sampler, - #[allow(dead_code)] - surface_uniform_buffer: wgpu::Buffer, + /// One reused uniform buffer holding [`SurfaceParams`] for every painted surface in a frame, + /// each at a distinct (alignment-strided) offset — same slotting scheme as + /// [`Self::blur_params_buffer`], for the same last-write-at-submit reason. + surface_params_buffer: wgpu::Buffer, + /// Stride between [`SurfaceParams`] slots in `surface_params_buffer`. + surface_params_stride: u64, /// One reused uniform buffer holding [`BlurParams`] for every blur pass in a frame, each at a /// distinct (alignment-strided) offset. Avoids allocating a buffer per pass; distinct offsets /// mean `write_buffer`'s last-write-at-submit semantics don't clobber earlier passes. @@ -264,6 +279,14 @@ const MAX_FILTER_DEPTH: usize = 2; /// Each frame uses 4 passes per backdrop/group plus one blit; 256 covers dozens of filters. const BLUR_PARAMS_SLOTS: u64 = 256; +/// Number of [`SurfaceParams`] slots in the shared surface-params buffer (one per painted surface +/// per frame). A frame paints one surface per open viewer (source + program = 2 today), so 256 +/// slots leave wide headroom. Distinct (alignment-strided) offsets are what keep each surface's +/// `write_buffer` from clobbering its neighbours' bounds under last-write-at-submit semantics — +/// a single shared slot collapsed every surface onto the last-written bounds and blacked out all +/// but one viewer. +const SURFACE_PARAMS_SLOTS: u64 = 256; + pub struct WgpuRenderer { /// Shared GPU context for device recovery coordination (unused on WASM). #[allow(dead_code)] @@ -421,6 +444,12 @@ impl WgpuRenderer { context.adapter.get_info().name ) })?; + log::info!( + "Surface adapter={:?} backend={:?} picked format={:?}", + context.adapter.get_info().name, + context.adapter.get_info().backend, + surface_format + ); let pick_alpha_mode = |preferences: &[wgpu::CompositeAlphaMode]| -> anyhow::Result { @@ -514,14 +543,18 @@ impl WgpuRenderer { ..Default::default() }); - let surface_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("surface_uniform_buffer"), - size: std::mem::size_of::() as u64, + let uniform_alignment = device.limits().min_uniform_buffer_offset_alignment as u64; + // Shared surface-params buffer: SURFACE_PARAMS_SLOTS slots, one per painted surface per + // frame, each one alignment stride apart (see SURFACE_PARAMS_SLOTS). + let surface_params_stride = + (std::mem::size_of::() as u64).next_multiple_of(uniform_alignment); + let surface_params_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("surface_params_buffer"), + size: surface_params_stride * SURFACE_PARAMS_SLOTS, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - let uniform_alignment = device.limits().min_uniform_buffer_offset_alignment as u64; // Shared blur-params buffer: BLUR_PARAMS_SLOTS slots, each one alignment stride apart. let blur_params_stride = (std::mem::size_of::() as u64).next_multiple_of(uniform_alignment); @@ -617,7 +650,8 @@ impl WgpuRenderer { bind_group_layouts, atlas_sampler, surface_sampler, - surface_uniform_buffer, + surface_params_buffer, + surface_params_stride, blur_params_buffer, globals_buffer, globals_bind_group, @@ -1879,7 +1913,8 @@ impl WgpuRenderer { #[cfg(any(target_os = "linux", target_os = "freebsd"))] fn draw_surfaces(&self, surfaces: &[PaintSurface], pass: &mut wgpu::RenderPass<'_>) -> bool { let resources = self.resources(); - for surface in surfaces { + let params_size = std::mem::size_of::() as u64; + for (slot, surface) in surfaces.iter().enumerate() { let Some(wgpu_texture) = surface.texture.downcast_ref::() else { continue; }; @@ -1891,9 +1926,12 @@ impl WgpuRenderer { content_mask: surface.content_mask.bounds.into(), }; + // Each surface writes its own alignment-strided slot (see SURFACE_PARAMS_SLOTS); + // a shared slot would leave every surface with the last-written bounds. + let offset = (slot as u64 % SURFACE_PARAMS_SLOTS) * resources.surface_params_stride; resources.queue.write_buffer( - &resources.surface_uniform_buffer, - 0, + &resources.surface_params_buffer, + offset, bytemuck::bytes_of(¶ms), ); @@ -1905,7 +1943,11 @@ impl WgpuRenderer { entries: &[ wgpu::BindGroupEntry { binding: 0, - resource: resources.surface_uniform_buffer.as_entire_binding(), + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &resources.surface_params_buffer, + offset, + size: std::num::NonZeroU64::new(params_size), + }), }, wgpu::BindGroupEntry { binding: 1, diff --git a/crates/gpui_widgets/src/viewer/mod.rs b/crates/gpui_widgets/src/viewer/mod.rs index 3a1a5ca4ed..aac84444a8 100644 --- a/crates/gpui_widgets/src/viewer/mod.rs +++ b/crates/gpui_widgets/src/viewer/mod.rs @@ -90,6 +90,7 @@ pub fn register_gpu_frame( /// Look up the GPU frame registered for `image_id`, if any. Does not remove /// the entry: the same image is reused on every cache hit. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] fn take_gpu_frame(image_id: usize) -> Option { GPU_FRAMES .lock() @@ -451,12 +452,18 @@ impl ViewerWidget { self.cpu_image = frame.clone(); self.frame_source = match frame { Some(image) => { + // The 10-bit GPU-texture upgrade is only available where + // [`SurfaceSource::Texture`] exists (linux/freebsd); elsewhere the + // BGRA8 CPU frame is used as-is. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] let source = take_gpu_frame(image.id.0).map(|entry| { ViewerFrameSource::Surface(SurfaceSource::Texture { texture: entry.texture, size: entry.size, }) }); + #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] + let source: Option = None; Some(source.unwrap_or(ViewerFrameSource::CpuFrame(image))) } None => None, diff --git a/crates/gpui_windows/src/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs index 00f647ff74..b63741974a 100644 --- a/crates/gpui_windows/src/directx_renderer.rs +++ b/crates/gpui_windows/src/directx_renderer.rs @@ -1206,6 +1206,13 @@ impl DirectXResources { height, )? }; + // sRGB is declared unconditionally: with Auto Color Management off + // the declaration is lazy (DXGI keeps the legacy default, which ACM + // also reads as sRGB), and with ACM on the app's policy is always + // OS-managed — the OS performs the one display mapping — so the + // sRGB declaration is exactly right either way. See + // `declare_srgb_swap_chain`. + declare_srgb_swap_chain(&swap_chain); let ( render_target, @@ -1750,6 +1757,29 @@ fn create_swap_chain( Ok(swap_chain) } +/// Declare the swap chain's content as sRGB (`SetColorSpace1`). +/// +/// The renderer always presents sRGB-encoded pixels; without an explicit +/// declaration DXGI assumes the legacy "RGB studio G22 none P709" default, +/// which Windows 11 Auto Color Management also treats as sRGB — but making +/// the declaration explicit removes the reliance on the implicit default and +/// is the single-mapping contract the app honors: the OS (DWM/ACM) performs +/// the one display mapping, and the app must not also apply the display ICC. +/// Best-effort: older drivers without `IDXGISwapChain3` keep the default. +fn declare_srgb_swap_chain(swap_chain: &IDXGISwapChain1) { + match swap_chain.cast::() { + Ok(swap_chain3) => unsafe { + if let Err(error) = swap_chain3.SetColorSpace1(DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709) + { + log::warn!("SetColorSpace1(sRGB) failed: {error}"); + } + }, + Err(error) => { + log::warn!("IDXGISwapChain3 unavailable, swap chain color space left implicit: {error}"); + } + } +} + #[inline] fn create_resources( devices: &DirectXRendererDevices, diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 90161a4248..450f481775 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -53,6 +53,11 @@ pub struct WindowsWindowState { pub border_offset: WindowBorderOffset, pub appearance: Cell, pub background_appearance: Cell, + /// The colorspace the window's content is declared to be in. Windows + /// Auto Color Management maps a swap chain only when it is declared + /// sRGB; non-sRGB declarations are handled by the app itself, so this + /// is informational here (see `set_content_colorspace`). + pub content_colorspace: Cell, pub scale_factor: Cell, pub restore_from_minimized: Cell>>, @@ -175,6 +180,7 @@ impl WindowsWindowState { border_offset, appearance: Cell::new(appearance), background_appearance: Cell::new(WindowBackgroundAppearance::Opaque), + content_colorspace: Cell::new(WindowContentColorspace::default()), scale_factor: Cell::new(scale_factor), restore_from_minimized: Cell::new(restore_from_minimized), min_size, @@ -1091,6 +1097,15 @@ impl PlatformWindow for WindowsWindow { self.state.renderer.borrow().gpu_specs().log_err() } + fn set_content_colorspace(&mut self, colorspace: WindowContentColorspace) { + // Informational on Windows: Auto Color Management maps a swap chain + // only when it is declared sRGB (`declare_srgb_swap_chain`), and the + // declaration is fixed at creation — non-sRGB content is mapped by + // the app's own pipeline, which reads this declaration. Nothing to + // re-tag at runtime. + self.state.content_colorspace.set(colorspace); + } + fn update_ime_position(&self, bounds: Bounds) { let scale_factor = self.state.scale_factor.get(); let caret_position = POINT {