TECH NOTE · AUTOMATION OPS
모니터마다 배율이 달라 보조 화면에서만 좌표가 어긋납니다. 어느 모니터의 배율을 쓸지 정하는 기준과 경계 처리입니다.
메인 모니터에서만 맞는다
다른 창의 위치를 읽어 그 위에 무언가를 올리는 기능을 만들었다. 메인 모니터에서는 정확히 맞았다.
보조 모니터로 옮기니 어긋났다. 조금이 아니라 눈에 띄게, 그리고 일정한 비율로 어긋났다.
비율이 힌트였다. 보조 모니터의 배율이 125%였고, 오차도 딱 그만큼이었다.
픽셀이 두 종류다
배율이 있는 화면에서는 좌표가 두 가지로 존재한다.
- 물리 픽셀 — 화면이 실제로 가진 점의 개수
- 논리 픽셀(포인트) — 앱이 다루는 좌표 단위
150% 배율의 3840×2160 모니터는 물리적으로 3840픽셀이지만, 앱에게는 2560포인트로 보인다. 글자와 UI가 1.5배로 커 보이는 이유다.
OS API가 어느 쪽을 돌려주는지는 API마다 다르다. Windows에서 창 경계를 읽는 함수들은 대체로 물리 픽셀을 준다.
DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, ...)
반면 프론트엔드(브라우저, Tauri의 창 API)는 논리 좌표로 일한다. 이 둘을 섞으면 배율만큼 어긋난다.
나눌 배율을 어느 모니터에서 가져오나
여기가 진짜 문제다. 배율은 시스템 값이 아니라 모니터마다 다르다.
노트북 내장 화면 200%에 외장 모니터 100%를 붙이는 건 흔한 구성이다. Windows는 이걸 per-monitor DPI로 지원한다.
배율을 읽는 코드 자체는 짧다.
pub fn monitor_scale(monitor: HMONITOR) -> f64 {
if monitor.is_null() {
return 1.0;
}
let mut dpi_x: UINT = 96;
let mut dpi_y: UINT = 96;
let hr = unsafe { GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, &mut dpi_x, &mut dpi_y) };
if hr >= 0 && dpi_x > 0 {
dpi_x as f64 / 96.0
} else {
1.0
}
}
96이 기준 DPI다. 120이면 125%, 144면 150%다. 실패하면 1.0으로 떨어뜨려 최소한 메인 모니터에서는 맞게 둔다.
문제는 어느 HMONITOR를 넣느냐다. 화면이 셋이면 답이 셋이다.
창 중심이 아니라 발 기준이다
처음에는 창의 중심점이 속한 모니터를 썼다. 자연스러워 보였다.
틀렸다. 창이 두 모니터에 걸쳐 있을 때 무너진다.
캐릭터 창은 320×320인데 캐릭터는 그 안 아래쪽에 서 있다. 창 중심이 왼쪽 모니터에 있어도 캐릭터의 발은 오른쪽 모니터에 있을 수 있다. 그러면 발이 서 있는 바닥의 좌표를 다른 모니터의 배율로 변환하게 된다.
기준을 발로 바꿨다.
let selected = select_display_for_point(feet_x, feet_y, &display_bounds);
let screen = selected.as_ref().map(|d| d.rect).unwrap_or(fallback_screen);
// Convert every intersecting window with the feet-monitor scale so coords
// line up with the frontend's Tauri logical screen for this monitor.
let scale = selected.as_ref().map(|d| d.scale).unwrap_or(1.0);
의미가 있는 점을 기준으로 삼는다는 게 규칙이다. 창의 기하학적 중심은 아무 의미가 없다. 실제로 계산에 쓰이는 지점 — 발, 커서, 클릭 좌표 — 이 속한 모니터의 배율을 쓴다.
점이 어느 화면에도 안 속할 때
모니터를 고르는 함수에도 함정이 있다.
화면들이 직사각형으로 딱 붙어 있지 않으면 어느 화면에도 안 들어가는 좌표가 생긴다. 세로 모니터를 옆에 두면 위아래에 빈 공간이 남는다. 모니터를 뽑는 순간에도 잠깐 그런 상태가 된다.
그래서 포함 판정만 하면 안 되고 대안이 필요하다.
fn select_display_for_point(x: f64, y: f64, displays: &[DisplayBounds]) -> Option<DisplayBounds> {
displays.iter().cloned().min_by(|left, right| {
let left_contains = rect_contains_point(left.rect, x, y);
let right_contains = rect_contains_point(right.rect, x, y);
right_contains
.cmp(&left_contains) // 1) 포함하는 쪽
.then_with(|| {
squared_distance_to_rect(x, y, left.rect)
.total_cmp(&squared_distance_to_rect(x, y, right.rect))
}) // 2) 가까운 쪽
.then_with(|| left.id.as_bytes().cmp(right.id.as_bytes())) // 3) id 순
})
}
세 단계다. 포함하는 화면이 있으면 그것, 없으면 가장 가까운 것, 거리도 같으면 id 순.
세 번째가 있어야 하는 이유는 안정성이다. 두 화면의 경계에 정확히 걸친 좌표에서 매 프레임 다른 모니터가 뽑히면, 배율이 왔다 갔다 하면서 위치가 떨린다. 임의로라도 순서를 고정하면 같은 입력에 항상 같은 답이 나온다.
거리 비교에 제곱근을 안 쓴 것도 의도다. 크기 비교만 하면 되니 sqrt가 필요 없다.
macOS는 이 문제가 없다
같은 기능을 macOS에서는 다르게 짰다.
// macOS: CGWindowListCopyWindowInfo with bounds/layer/owner-pid only
CGWindowListCopyWindowInfo가 돌려주는 좌표는 이미 전역 논리 좌표다. 화면마다 배율이 달라도 하나의 좌표계 안에 놓인다. 변환할 게 없다.
그래서 macOS 분기에는 배율 코드가 아예 없다. Windows 분기에만 있다.
같은 개념이 플랫폼마다 다른 층에서 처리된다는 걸 알아 두면 좋다. macOS는 OS가 통합 좌표계를 주고, Windows는 앱이 직접 맞춘다. 어느 쪽이 낫다기보다, 크로스 플랫폼 코드를 쓸 때 이 차이를 공통 함수로 감추면 한쪽이 반드시 틀어진다.
두 플랫폼의 출력이 만나는 지점만 계약으로 못 박는 편이 낫다. 이 코드는 파일 첫머리에 그걸 적어 뒀다.
Everything stays in global display coordinates (logical points, y down),
the same space `pet_core::terrain` expects.
확인하는 법
배율 문제인지 아닌지는 금방 가린다. 오차가 좌표에 비례하면 배율, 일정하면 원점 문제다.
- 오차가 위치와 무관하게 항상 40픽셀 → 원점(작업 영역, 타이틀바 높이)
- 화면 오른쪽으로 갈수록 오차가 커짐 → 배율
- 특정 모니터에서만 발생 → per-monitor 배율
세 번째가 가장 흔하고, 메인 모니터 하나로 개발하면 절대 안 나온다.
정리
- 배율이 있는 화면에서는 물리 픽셀과 논리 픽셀이 다르다. OS API마다 주는 단위가 다르다
- 배율은 시스템 값이 아니라 모니터마다 다르다(Windows per-monitor DPI)
- 어느 모니터의 배율을 쓸지 정할 때, 창 중심이 아니라 실제 계산에 쓰이는 점을 기준으로 삼는다
- 화면 사이 빈 공간이 있으므로 포함 판정만으로는 부족하다. 가까운 화면으로 떨어뜨린다
- 동점일 때 순서를 고정한다. 안 그러면 경계에서 배율이 떨린다
- macOS는 통합 논리 좌표를 주므로 이 변환이 없다. 플랫폼 차이를 억지로 감추지 않는다
- 오차가 좌표에 비례하면 배율, 일정하면 원점이다
보조 모니터를 안 쓰고 개발하면 이 버그는 사용자가 먼저 찾는다.