返回

Rust 解决 Windows 多显示器颜色滤镜 (SetDeviceGammaRamp)

windows

在 Windows 上用 Rust 给所有显示器加上颜色滤镜

写程序有时候就是这样,一个功能看着简单,真做起来发现“坑”还不少。比如你想用 Rust 给 Windows 屏幕加个滤镜,类似夜间模式或者降低亮度的效果。你可能搜到了一些用 SetDeviceGammaRamp 的方法,写出来一试,哎,怎么只有一个显示器变色了?别急,这问题挺常见的,咱们来盘它。

问题在哪?为什么只影响一个屏幕?

你碰到的情况,根源在于 Windows 处理显示设备的方式。看看你最初的代码片段(或者类似思路的代码):

// 简化示意,非完整代码
unsafe {
    // 获取 *整个桌面* 的设备上下文 (Device Context - HDC)
    let hdc = GetDC(null_mut());
    if hdc.is_null() {
        // ... 错误处理 ...
        return;
    }
    
    // ... 计算 gamma ramp 数据 ...
    let mut gamma_ramp: [u16; 256 * 3] = // ... 计算 ...;

    // 用这个全局 HDC 设置 Gamma
    if SetDeviceGammaRamp(hdc, gamma_ramp.as_ptr() as *mut _) == 0 {
        // ... 错误处理 ...
    }

    // 释放全局 HDC
    ReleaseDC(null_mut(), hdc);
}

关键在于 GetDC(null_mut())。这个函数调用返回的是代表 整个虚拟屏幕 (virtual screen) 的设备上下文句柄(HDC)。在大多数情况下,这个“虚拟屏幕”主要映射到你的 主显示器 。所以,当你用这个 HDC 去调用 SetDeviceGammaRamp 时,自然只有主显示器响应了你的 Gamma 设置。

想让所有显示器都应用滤镜,思路就得变一变:不能再用那个“全局”的 HDC 了,而是要 分别找到每个物理显示器,获取它们各自的 HDC,然后对每个 HDC 单独调用 SetDeviceGammaRamp

解决方案:挨个“点名”显示器

要搞定所有显示器,咱们得分几步走:

  1. 找出所有连接的显示器。
  2. 获取每个显示器的具体信息,特别是它的设备名。
  3. 根据设备名,为每个显示器创建独立的设备上下文 (HDC)。
  4. 用各自的 HDC 调用 SetDeviceGammaRamp 来应用滤镜。
  5. 记得释放所有创建的资源。

听起来步骤挺多,但别担心,Windows API 提供了相应的工具。我们主要会用到 EnumDisplayMonitorsGetMonitorInfoWCreateDCW

下面我们分步细说,并给出 Rust 实现的思路和代码片段(使用 windows-sys crate)。

第一步:枚举所有显示器

Windows API 提供了 EnumDisplayMonitors 函数,它可以遍历当前系统上所有的显示器。这个函数需要一个回调函数作为参数,每找到一个显示器,Windows 就会调用你提供的这个回调函数,并把显示器的句柄 (HMONITOR) 和设备上下文句柄 (HDC) 传给你。

use windows_sys::Win32::{
    Foundation::{BOOL, LPARAM, RECT},
    Graphics::Gdi::{EnumDisplayMonitors, HDC, HMONITOR},
};
use std::ffi::c_void;

// 用来收集所有显示器句柄的 Vec
let mut monitors: Vec<HMONITOR> = Vec::new();

// EnumDisplayMonitors 的回调函数需要是 extern "system" fn
extern "system" fn monitor_enum_proc(
    hmonitor: HMONITOR,
    _hdc_monitor: HDC, // 这个 HDC 不一定是我们最终需要的
    _lprc_monitor: *mut RECT,
    dw_data: LPARAM,
) -> BOOL {
    // dw_data 是我们传进来的附加参数,这里就是 monitors Vec 的指针
    let monitors_vec = unsafe { &mut *(dw_data as *mut Vec<HMONITOR>) };
    monitors_vec.push(hmonitor);
    // 返回 TRUE 继续枚举,返回 FALSE 停止
    1 // TRUE
}

// 开始枚举
unsafe {
    // 第一个参数 hdc:设为 0 (null) 表示枚举整个虚拟桌面上的显示器
    // 第二个参数 lprcClip:设为 null 表示不限制枚举区域
    // 第三个参数 lpfnEnum:指向我们的回调函数
    // 第四个参数 dwData:传递给回调函数的附加数据,这里把 monitors 的指针传过去
    EnumDisplayMonitors(
        0, // null HDC for the entire virtual screen
        std::ptr::null(), // no clipping rectangle
        Some(monitor_enum_proc),
        &mut monitors as *mut Vec<HMONITOR> as LPARAM,
    );
}

// 执行完上面这块代码后,monitors 这个 Vec 里就包含了所有显示器的 HMONITOR 句柄
println!("找到了 {} 个显示器。", monitors.len());

原理和作用:

  • EnumDisplayMonitors 是 Windows 用来发现显示器的标准方法。
  • 回调函数 (monitor_enum_proc) 是核心,它会在每个显示器被发现时执行。
  • 我们通过 dwData 参数,巧妙地把外部的 Vec (用于存储 HMONITOR) 传入回调函数内部,从而收集所有句柄。注意这里涉及到裸指针和 unsafe,需要确保生命周期和类型转换正确。

第二步:获取每个显示器的设备名

光有 HMONITOR 还不够,SetDeviceGammaRamp 需要的是 HDC。虽然 EnumDisplayMonitors 的回调给了一个 HDC,但那个 HDC 可能并不适用于设置 Gamma(文档对此说明比较模糊,保险起见我们自己创建)。

创建特定显示器的 HDC,通常需要这个显示器关联的 设备名称 。我们可以用 GetMonitorInfoW 函数,传入 HMONITOR 来获取包含设备名的 MONITORINFOEXW 结构体。

use windows_sys::Win32::Graphics::Gdi::{
    GetMonitorInfoW, MonitorInfoFlags, MONITORINFOEXW, CCHDEVICENAME, HDC, HMONITOR
};
use std::{mem, ffi::{OsString, c_void}};
use std::os::windows::ffi::OsStringExt;

fn get_monitor_device_name(hmonitor: HMONITOR) -> Option<String> {
    let mut monitor_info: MONITORINFOEXW = unsafe { mem::zeroed() };
    // 重要:设置 cbSize 字段,这是 Windows API 的常见模式
    monitor_info.monitorInfo.cbSize = mem::size_of::<MONITORINFOEXW>() as u32;

    let success = unsafe {
        GetMonitorInfoW(
            hmonitor,
            &mut monitor_info as *mut MONITORINFOEXW as *mut _, // 需要转成基类指针
        ) != 0 // 返回非零表示成功
    };

    if success {
        // szDevice 是一个 [u16; CCHDEVICENAME] 数组
        // 需要找到空终止符(null terminator)并转换为 Rust 字符串
        let device_name_wide: &[u16] = &monitor_info.szDevice;
        // 查找第一个 0 (null terminator)
        let null_pos = device_name_wide.iter().position(|&c| c == 0).unwrap_or(CCHDEVICENAME as usize);
        let os_string = OsString::from_wide(&device_name_wide[..null_pos]);
        os_string.into_string().ok() // 尝试转换为 UTF-8 String
    } else {
        eprintln!("获取显示器信息失败 (HMONITOR: {:?})", hmonitor);
        None
    }
}

// 示例:遍历之前获取的 monitors 列表
// for hmon in &monitors {
//     if let Some(name) = get_monitor_device_name(*hmon) {
//         println!("显示器 {:?} 的设备名: {}", hmon, name);
//     }
// }

原理和作用:

  • GetMonitorInfoW 通过 HMONITOR 获取显示器的详细信息,包括它的设备名称 (szDevice),这个名称是识别具体显示设备的关键。
  • 注意处理 szDevice 字段。它是一个定长的宽字符数组(UTF-16),需要正确地从中提取出以空字符结尾的字符串,并转换为 Rust 的 String

第三步:创建并使用特定显示器的 HDC

拿到设备名之后,就可以用 CreateDCW 函数来创建这个显示器专属的 HDC 了。CreateDCW 需要驱动名(通常是"DISPLAY")、设备名和端口名(通常是 null)。

use windows_sys::Win32::Graphics::Gdi::{CreateDCW, DeleteDC, HDC, SetDeviceGammaRamp};
use windows_sys::core::PCWSTR;
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use std::{ptr, mem};

fn apply_filter_to_monitor(device_name: &str, gamma_ramp: &[u16; 256 * 3]) -> bool {
    // 将 Rust 字符串转换为 Windows 需要的宽字符 (UTF-16) C 风格字符串
    let device_name_wide: Vec<u16> = OsStr::new(device_name)
                                        .encode_wide()
                                        .chain(std::iter::once(0)) // 添加 null terminator
                                        .collect();
    let driver_name: Vec<u16> = OsStr::new("DISPLAY")
                                    .encode_wide()
                                    .chain(std::iter::once(0))
                                    .collect();

    let hdc: HDC = unsafe {
        CreateDCW(
            driver_name.as_ptr(),   // lpszDriver: 通常是 "DISPLAY"
            device_name_wide.as_ptr(), // lpszDevice: 显示器设备名
            ptr::null(),            // lpszOutput: 通常是 null
            ptr::null(),            // lpInitData: 通常是 null
        )
    };

    if hdc == 0 { // HDC 为 0 或 -1 (INVALID_HANDLE_VALUE) 都表示失败
        eprintln!("为设备 {} 创建 HDC 失败", device_name);
        return false;
    }

    let success;
    unsafe {
        // 现在用这个特定显示器的 HDC 设置 Gamma Ramp
        success = SetDeviceGammaRamp(hdc, gamma_ramp.as_ptr() as *const c_void) != 0;
        if !success {
             let error_code = windows_sys::Win32::Foundation::GetLastError();
            eprintln!("SetDeviceGammaRamp 失败 (设备: {}), 错误码: {}", device_name, error_code);
        }
        
        // 非常重要:释放用 CreateDCW 创建的 HDC
        if DeleteDC(hdc) == 0 {
             eprintln!("警告: DeleteDC 失败 (设备: {})", device_name);
        }
    }

    success
}

// 结合前面的步骤,在主函数或逻辑中调用:
// let gamma_ramp = calculate_gamma_ramp(...); // 先计算好 gamma 数据
// for hmon in &monitors {
//     if let Some(name) = get_monitor_device_name(*hmon) {
//         if !apply_filter_to_monitor(&name, &gamma_ramp) {
//             eprintln!("未能成功为显示器 {} 应用滤镜", name);
//         } else {
//             println!("已为显示器 {} 应用滤镜", name);
//         }
//     }
// }

原理和作用:

  • CreateDCW 通过设备名直接为某个显示设备创建了一个设备上下文 HDC。这是获取特定显示器控制权的关键一步。
  • 拿到这个 HDC 后,SetDeviceGammaRamp 的调用就只会影响这一个显示器了。
  • 安全注意!CreateDCW 创建的 HDC 必须 使用 DeleteDC 来释放,否则会造成资源泄漏。这与 GetDC(用 ReleaseDC 释放)不同。

整合代码:一个完整的函数示例

现在把这些步骤整合一下,形成一个可以接受亮度和色温、并应用到所有显示器的函数:

use windows_sys::Win32::{
    Foundation::{BOOL, LPARAM, RECT, GetLastError},
    Graphics::Gdi::{
        EnumDisplayMonitors, GetMonitorInfoW, MonitorInfoFlags, MONITORINFOEXW, CCHDEVICENAME,
        CreateDCW, DeleteDC, SetDeviceGammaRamp, HDC, HMONITOR,
    },
};
use std::{mem, ptr, ffi::{c_void, OsStr, OsString}};
use std::os::windows::ffi::{OsStrExt, OsStringExt};

// (你的 get_warm_light_rgb 函数保持不变)
fn get_warm_light_rgb(temperature: u32) -> (u8, u8, u8) {
    match temperature {
        6500 => (255, 255, 255), // Neutral
        5000 => (255, 228, 206),
        4500 => (255, 213, 181),
        4000 => (255, 197, 158),
        3500 => (255, 180, 136),
        3000 => (255, 165, 114),
        2700 => (255, 152, 96),
        2500 => (255, 138, 76),
        2000 => (255, 120, 50),
        1800 => (255, 109, 35),
        _ => (255, 255, 255), // Default or unknown temperature
    }
}


// 计算 Gamma Ramp 数据
fn calculate_gamma_ramp(brightness: u32, color_temperature: u32) -> [u16; 256 * 3] {
    let (r_adj, g_adj, b_adj) = get_warm_light_rgb(color_temperature);
    let mut gamma_ramp: [u16; 256 * 3] = [0; 256 * 3];
    // 确保亮度在合理范围内,例如 10-100
    let clamped_brightness = brightness.max(10).min(100) as f64 / 100.0; 

    for i in 0..256 {
        let base_value = (i as f64).powf(1.0 / 2.2); // 简单的 Gamma 校正模拟
        let scaled_value = base_value * 255.0 * clamped_brightness;

        // 应用色温调整因子
        let r_factor = r_adj as f64 / 255.0;
        let g_factor = g_adj as f64 / 255.0;
        let b_factor = b_adj as f64 / 255.0;

        // 计算最终 ramp 值,并转换为 u16 (0-65535)
        let r = (scaled_value * r_factor).clamp(0.0, 255.0) as u16 * 256;
        let g = (scaled_value * g_factor).clamp(0.0, 255.0) as u16 * 256;
        let b = (scaled_value * b_factor).clamp(0.0, 255.0) as u16 * 256;

        // Windows Gamma Ramp 需要 u16 的值,范围 0-65535
        // ramp[0..255] 是 R, ramp[256..511] 是 G, ramp[512..767] 是 B
        gamma_ramp[i] = r.min(65535);
        gamma_ramp[i + 256] = g.min(65535);
        gamma_ramp[i + 512] = b.min(65535);
    }
    gamma_ramp
}

// 主函数:应用滤镜到所有显示器
fn apply_filter_to_all_displays(brightness: u32, color_temperature: u32) {
    println!(
        "准备应用滤镜到所有显示器: 亮度 {}%, 色温 {}K",
        brightness, color_temperature
    );

    let gamma_ramp = calculate_gamma_ramp(brightness, color_temperature);

    // 1. 枚举所有显示器 HMONITOR
    let mut monitors: Vec<HMONITOR> = Vec::new();
    unsafe {
        EnumDisplayMonitors(
            0,
            ptr::null(),
            Some(monitor_enum_proc),
            &mut monitors as *mut Vec<HMONITOR> as LPARAM,
        );
    }

    if monitors.is_empty() {
        eprintln!("错误:没有找到任何显示器!");
        return;
    }

    println!("找到 {} 个显示器,开始逐个应用滤镜...", monitors.len());
    let driver_name_wide: Vec<u16> = OsStr::new("DISPLAY").encode_wide().chain(std::iter::once(0)).collect();

    for (index, &hmonitor) in monitors.iter().enumerate() {
        // 2. 获取设备名
        let device_name_opt = get_monitor_device_name(hmonitor);
        
        if let Some(device_name) = device_name_opt {
             println!("  处理显示器 {}: {}", index + 1, device_name);
             let device_name_wide: Vec<u16> = OsStr::new(&device_name).encode_wide().chain(std::iter::once(0)).collect();

            // 3. 创建 HDC
            let hdc: HDC = unsafe {
                CreateDCW(
                    driver_name_wide.as_ptr(),
                    device_name_wide.as_ptr(),
                    ptr::null(),
                    ptr::null(),
                )
            };

            if hdc == 0 {
                let err = unsafe { GetLastError() };
                eprintln!("    错误: 为 {} 创建 HDC 失败, 错误码: {}", device_name, err);
                continue; // 跳过这个显示器
            }

            // 4. 应用 Gamma Ramp
            let success = unsafe { SetDeviceGammaRamp(hdc, gamma_ramp.as_ptr() as *const c_void) != 0 };

            if success {
                println!("    成功: 已应用 Gamma Ramp.");
            } else {
                let err = unsafe { GetLastError() };
                eprintln!("    错误: SetDeviceGammaRamp 失败, 错误码: {}", err);
            }

            // 5. 清理 HDC
            unsafe {
                if DeleteDC(hdc) == 0 {
                     let err = unsafe { GetLastError() };
                     eprintln!("    警告: DeleteDC 失败, 错误码: {}", err);
                }
            }

        } else {
             eprintln!("  处理显示器 {}: 获取设备名失败,跳过。", index + 1);
        }
    }
     println!("所有显示器处理完毕。");
}


// 回调函数放在外面,或者作为内部函数定义
extern "system" fn monitor_enum_proc(hmonitor: HMONITOR, _hdc: HDC, _rect: *mut RECT, data: LPARAM) -> BOOL {
    let monitors = unsafe { &mut *(data as *mut Vec<HMONITOR>) };
    monitors.push(hmonitor);
    1 // Continue enumeration
}

// (get_monitor_device_name 函数也需要定义在这里,参考前面的代码)
fn get_monitor_device_name(hmonitor: HMONITOR) -> Option<String> {
     let mut monitor_info: MONITORINFOEXW = unsafe { mem::zeroed() };
    monitor_info.monitorInfo.cbSize = mem::size_of::<MONITORINFOEXW>() as u32;
    let success = unsafe { GetMonitorInfoW(hmonitor, &mut monitor_info as *mut _ as *mut _) != 0 };
    if success {
        let device_name_wide = &monitor_info.szDevice[..];
        let null_pos = device_name_wide.iter().position(|&c| c == 0).unwrap_or(CCHDEVICENAME as usize);
        let os_string = OsString::from_wide(&device_name_wide[..null_pos]);
        os_string.into_string().ok()
    } else {
        let err = unsafe{ GetLastError() };
        eprintln!("GetMonitorInfoW 失败 for HMONITOR {:?}, 错误码: {}", hmonitor, err);
        None
    }
}


// 在你的 main 函数或者其他地方调用:
// fn main() {
//     apply_filter_to_all_displays(80, 3500); // 示例:80% 亮度,3500K 色温
//     // 你可能需要保持程序运行,或者在适当的时候恢复默认 Gamma
// }

补充说明和技巧

  1. Gamma Ramp 计算 :上面 calculate_gamma_ramp 里的计算逻辑是一个基础版本。你可以根据需要调整亮度曲线(比如使用更精确的 Gamma 校正 powf(1.0 / gamma_value))和颜色混合算法,来实现更自然的夜间模式或亮度调节。原始代码中的亮度因子是直接乘上去的,这里改成了基于感知亮度的调整,你可以对比效果。
  2. 错误处理 :Windows API 调用可能会失败。务必检查返回值 (HDC 是否为 0,SetDeviceGammaRamp 返回 0 等),并可以使用 GetLastError() 获取具体的错误码,方便调试。
  3. 性能 :枚举显示器、获取信息、创建/删除 HDC 这些操作相对比较快,通常不会成为性能瓶颈,除非你非常频繁地(比如每秒多次)改变滤镜。SetDeviceGammaRamp 本身应用是很快的。
  4. 权限 :通常修改 Gamma Ramp 不需要特殊的管理员权限。
  5. 恢复默认设置 :别忘了提供一个恢复默认 Gamma 设置的功能。最简单的方式是计算一个“正常”的 Gamma Ramp(亮度100%,色温6500K,或者干脆生成一个线性 Ramp),然后用同样的方法应用到所有显示器。或者,可以在程序启动时先用 GetDeviceGammaRamp 保存每个显示器当前的 Gamma 值,以便后续恢复。
  6. 依赖管理 (Cargo.toml) :确保你的 Cargo.toml 文件里添加了 windows-sys 依赖,并启用了需要的功能(features),例如 "Win32_Graphics_Gdi", "Win32_Foundation" 等。
[dependencies.windows-sys]
version = "0.52" # 或者最新版本
features = [
    "Win32_Foundation",
    "Win32_Graphics_Gdi",
    "Win32_UI_WindowsAndMessaging", # GetLastError 等可能在这里面
]

现在,你的 Rust 程序应该就能正确地给所有连接的 Windows 显示器统一应用颜色滤镜了。这个方法虽然依赖了一些底层的 Windows API,但对于控制 Gamma Ramp 这种级别的显示设置,它是相当直接和有效的方式。