diff --git a/crates/pf-capture/src/windows/idd_push.rs b/crates/pf-capture/src/windows/idd_push.rs index e8265638..8de80498 100644 --- a/crates/pf-capture/src/windows/idd_push.rs +++ b/crates/pf-capture/src/windows/idd_push.rs @@ -429,6 +429,10 @@ pub struct IddPushCapturer { last_blend_key: Option<(u64, i32, i32, bool)>, /// The ring slot of the last FRESH publish — the regen source. last_slot: Option, + /// The target's SDR-white scale (vs 80 nits) for HDR cursor compositing — refreshed on + /// each blend-scratch rebuild (first use + ring geometry changes). 2.5 ≈ the Windows + /// SDR-brightness default; without it the composited cursor renders visibly dark on HDR. + sdr_white_scale: f32, width: u32, height: u32, slots: Vec, @@ -1090,6 +1094,7 @@ impl IddPushCapturer { blend_scratch: None, last_blend_key: None, last_slot: None, + sdr_white_scale: 1.0, // Held from BEFORE the first-frame gate (the display must not idle off while we // wait for the first compose) until the capturer drops with the session. _display_wake: pf_frame::session_tuning::DisplayWakeRequest::new(), @@ -1819,9 +1824,14 @@ impl IddPushCapturer { } } if let Some(pass) = self.cursor_blend.as_mut() { - // FP16 ring = scRGB linear composition (HDR): linearize the sRGB shape. - let is_linear = self.display_hdr; - if let Err(e) = pass.blend(&self.device, &self.context, &tex, &ov, is_linear) { + // FP16 ring = scRGB linear composition (HDR): linearize the sRGB shape and + // scale it to the target's SDR white so it matches the desktop around it. + let scale = if self.display_hdr { + self.sdr_white_scale + } else { + 0.0 + }; + if let Err(e) = pass.blend(&self.device, &self.context, &tex, &ov, scale) { if !self.cursor_blend_failed { self.cursor_blend_failed = true; tracing::warn!("cursor blend draw failed — pointer-less frames: {e:#}"); diff --git a/crates/pf-capture/src/windows/idd_push/cursor_blend.rs b/crates/pf-capture/src/windows/idd_push/cursor_blend.rs index d6d1f134..6c253a4d 100644 --- a/crates/pf-capture/src/windows/idd_push/cursor_blend.rs +++ b/crates/pf-capture/src/windows/idd_push/cursor_blend.rs @@ -26,16 +26,18 @@ use windows::Win32::Graphics::Direct3D11::{ }; use windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_R8G8B8A8_UNORM; -/// Straight-alpha sample of the cursor bitmap; `to_linear` ≠ 0 linearizes sRGB→scRGB for an -/// FP16 (HDR-composed) frame so the cursor doesn't glow at 8× SDR white. +/// Straight-alpha sample of the cursor bitmap. `linear_scale` = 0 passes sRGB through (SDR +/// ring); non-zero linearizes sRGB→scRGB AND multiplies by the target's SDR-white scale +/// (`sdr_white_level_scale` — 1.0 would put cursor-white at 80 nits, visibly DARKER than the +/// surrounding SDR desktop content DWM composes at the user's SDR-brightness setting). const CURSOR_PS: &str = r" Texture2D tx : register(t0); SamplerState sm : register(s0); -cbuffer C : register(b0) { float to_linear; float3 pad; }; +cbuffer C : register(b0) { float linear_scale; float3 pad; }; float4 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_Target { float4 c = tx.Sample(sm, uv); - if (to_linear != 0.0) { - c.rgb = pow(abs(c.rgb), 2.2); + if (linear_scale != 0.0) { + c.rgb = pow(abs(c.rgb), 2.2) * linear_scale; } return c; } @@ -48,7 +50,7 @@ pub(super) struct CursorBlendPass { sampler: ID3D11SamplerState, blend: ID3D11BlendState, cbuf: ID3D11Buffer, - cbuf_is_linear: Option, + cbuf_scale: Option, /// The uploaded shape (serial-keyed): SRV + dims in host pixels. shape: Option<(u64, ID3D11ShaderResourceView, u32, u32)>, } @@ -103,7 +105,7 @@ impl CursorBlendPass { sampler: sampler.context("cursor blend sampler")?, blend: blend.context("cursor blend state")?, cbuf: cbuf.context("cursor blend cbuf")?, - cbuf_is_linear: None, + cbuf_scale: None, shape: None, }) } @@ -153,21 +155,22 @@ impl CursorBlendPass { } /// Alpha-blend `ov` onto `dst` (frame-sized, RENDER_TARGET-capable — the blend scratch). - /// `is_linear` = the frame is FP16 scRGB (HDR composition). The quad is placed purely via - /// the viewport (the fullscreen-triangle VS fills whatever viewport is set), clipped by the - /// target automatically. + /// `linear_scale`: 0 = SDR passthrough; non-zero = the frame is FP16 scRGB (HDR + /// composition) — linearize and scale to the target's SDR white. The quad is placed purely + /// via the viewport (the fullscreen-triangle VS fills whatever viewport is set), clipped by + /// the target automatically. pub(super) unsafe fn blend( &mut self, device: &ID3D11Device, ctx: &ID3D11DeviceContext, dst: &ID3D11Texture2D, ov: &pf_frame::CursorOverlay, - is_linear: bool, + linear_scale: f32, ) -> Result<()> { self.ensure_shape(device, ov)?; let (_, srv, w, h) = self.shape.as_ref().expect("shape just ensured"); - if self.cbuf_is_linear != Some(is_linear) { - let cb: [f32; 4] = [if is_linear { 1.0 } else { 0.0 }, 0.0, 0.0, 0.0]; + if self.cbuf_scale != Some(linear_scale) { + let cb: [f32; 4] = [linear_scale, 0.0, 0.0, 0.0]; let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); if ctx .Map(&self.cbuf, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped)) @@ -176,7 +179,7 @@ impl CursorBlendPass { std::ptr::copy_nonoverlapping(cb.as_ptr(), mapped.pData as *mut f32, cb.len()); ctx.Unmap(&self.cbuf, 0); } - self.cbuf_is_linear = Some(is_linear); + self.cbuf_scale = Some(linear_scale); } let mut rtv: Option = None; device diff --git a/crates/pf-win-display/src/win_display.rs b/crates/pf-win-display/src/win_display.rs index ffaf610b..5850c94c 100644 --- a/crates/pf-win-display/src/win_display.rs +++ b/crates/pf-win-display/src/win_display.rs @@ -23,10 +23,10 @@ use windows::core::PCWSTR; use windows::Win32::Devices::Display::{ DisplayConfigGetDeviceInfo, DisplayConfigSetDeviceInfo, GetDisplayConfigBufferSizes, QueryDisplayConfig, SetDisplayConfig, DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO, - DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME, - DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, - DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE, - DISPLAYCONFIG_OUTPUT_TECHNOLOGY_COMPONENT_VIDEO, + DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL, DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, + DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME, DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE, + DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO, + DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE, DISPLAYCONFIG_OUTPUT_TECHNOLOGY_COMPONENT_VIDEO, DISPLAYCONFIG_OUTPUT_TECHNOLOGY_COMPOSITE_VIDEO, DISPLAYCONFIG_OUTPUT_TECHNOLOGY_DISPLAYPORT_EMBEDDED, DISPLAYCONFIG_OUTPUT_TECHNOLOGY_DISPLAYPORT_EXTERNAL, DISPLAYCONFIG_OUTPUT_TECHNOLOGY_DVI, @@ -35,10 +35,11 @@ use windows::Win32::Devices::Display::{ DISPLAYCONFIG_OUTPUT_TECHNOLOGY_SDI, DISPLAYCONFIG_OUTPUT_TECHNOLOGY_SDTVDONGLE, DISPLAYCONFIG_OUTPUT_TECHNOLOGY_SVIDEO, DISPLAYCONFIG_OUTPUT_TECHNOLOGY_UDI_EMBEDDED, DISPLAYCONFIG_OUTPUT_TECHNOLOGY_UDI_EXTERNAL, DISPLAYCONFIG_PATH_INFO, - DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_SOURCE_DEVICE_NAME, - DISPLAYCONFIG_TARGET_DEVICE_NAME, DISPLAYCONFIG_VIDEO_OUTPUT_TECHNOLOGY, QDC_ALL_PATHS, - QDC_ONLY_ACTIVE_PATHS, SDC_ALLOW_CHANGES, SDC_APPLY, SDC_FORCE_MODE_ENUMERATION, - SDC_SAVE_TO_DATABASE, SDC_TOPOLOGY_EXTEND, SDC_USE_SUPPLIED_DISPLAY_CONFIG, + DISPLAYCONFIG_SDR_WHITE_LEVEL, DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE, + DISPLAYCONFIG_SOURCE_DEVICE_NAME, DISPLAYCONFIG_TARGET_DEVICE_NAME, + DISPLAYCONFIG_VIDEO_OUTPUT_TECHNOLOGY, QDC_ALL_PATHS, QDC_ONLY_ACTIVE_PATHS, SDC_ALLOW_CHANGES, + SDC_APPLY, SDC_FORCE_MODE_ENUMERATION, SDC_SAVE_TO_DATABASE, SDC_TOPOLOGY_EXTEND, + SDC_USE_SUPPLIED_DISPLAY_CONFIG, }; use windows::Win32::Foundation::POINTL; use windows::Win32::Graphics::Gdi::{ @@ -481,6 +482,52 @@ pub unsafe fn advanced_color_enabled(target_id: u32) -> Option { None } +/// The target's SDR white level as a SCALE relative to 80 nits (`1.0` = 80 nits): where DWM +/// places SDR-white when composing SDR content onto this HDR desktop. An SDR-authored overlay +/// (the composited cursor) must be multiplied by this in scRGB space or it renders visibly +/// darker than the surrounding SDR desktop content (the Windows "SDR content brightness" +/// slider default alone is ~2.5x). `None` = query failed / target not active (callers keep +/// their last value or 1.0). +/// +/// # Safety +/// Runs the read-only CCD query FFI over owned locals (same shape as [`advanced_color_enabled`]). +pub unsafe fn sdr_white_level_scale(target_id: u32) -> Option { + let mut np = 0u32; + let mut nm = 0u32; + if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() { + return None; + } + let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize]; + let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize]; + if QueryDisplayConfig( + QDC_ONLY_ACTIVE_PATHS, + &mut np, + paths.as_mut_ptr(), + &mut nm, + modes.as_mut_ptr(), + None, + ) + .is_err() + { + return None; + } + for p in paths.iter().take(np as usize) { + if p.targetInfo.id == target_id { + let mut info = DISPLAYCONFIG_SDR_WHITE_LEVEL::default(); + info.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL; + info.header.size = size_of::() as u32; + info.header.adapterId = p.targetInfo.adapterId; + info.header.id = p.targetInfo.id; + if DisplayConfigGetDeviceInfo(&mut info.header) == 0 && info.SDRWhiteLevel > 0 { + // Contract: SDRWhiteLevel/1000 * 80 = nits, i.e. the /1000 IS the 80-nit scale. + return Some(info.SDRWhiteLevel as f32 / 1000.0); + } + return None; + } + } + None +} + /// Force the freshly-added virtual monitor to the client's exact `WxH@Hz`. The ADD IOCTL only /// ADVERTISES the mode; Windows otherwise activates an IDD target at a 1280x720 default, so the /// ACTIVE mode (what DXGI Desktop Duplication captures) must be set explicitly. CDS_TEST first so a