Rust 解决 Windows 多显示器颜色滤镜 (SetDeviceGammaRamp)
2025-03-28 10:07:03
在 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
。
解决方案:挨个“点名”显示器
要搞定所有显示器,咱们得分几步走:
- 找出所有连接的显示器。
- 获取每个显示器的具体信息,特别是它的设备名。
- 根据设备名,为每个显示器创建独立的设备上下文 (HDC)。
- 用各自的 HDC 调用
SetDeviceGammaRamp
来应用滤镜。 - 记得释放所有创建的资源。
听起来步骤挺多,但别担心,Windows API 提供了相应的工具。我们主要会用到 EnumDisplayMonitors
、GetMonitorInfoW
和 CreateDCW
。
下面我们分步细说,并给出 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
// }
补充说明和技巧
- Gamma Ramp 计算 :上面
calculate_gamma_ramp
里的计算逻辑是一个基础版本。你可以根据需要调整亮度曲线(比如使用更精确的 Gamma 校正powf(1.0 / gamma_value)
)和颜色混合算法,来实现更自然的夜间模式或亮度调节。原始代码中的亮度因子是直接乘上去的,这里改成了基于感知亮度的调整,你可以对比效果。 - 错误处理 :Windows API 调用可能会失败。务必检查返回值 (
HDC
是否为 0,SetDeviceGammaRamp
返回 0 等),并可以使用GetLastError()
获取具体的错误码,方便调试。 - 性能 :枚举显示器、获取信息、创建/删除 HDC 这些操作相对比较快,通常不会成为性能瓶颈,除非你非常频繁地(比如每秒多次)改变滤镜。
SetDeviceGammaRamp
本身应用是很快的。 - 权限 :通常修改 Gamma Ramp 不需要特殊的管理员权限。
- 恢复默认设置 :别忘了提供一个恢复默认 Gamma 设置的功能。最简单的方式是计算一个“正常”的 Gamma Ramp(亮度100%,色温6500K,或者干脆生成一个线性 Ramp),然后用同样的方法应用到所有显示器。或者,可以在程序启动时先用
GetDeviceGammaRamp
保存每个显示器当前的 Gamma 值,以便后续恢复。 - 依赖管理 (
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 这种级别的显示设置,它是相当直接和有效的方式。