콘텐츠로 건너뛰기
Codex

모니터마다 배율이 다를 때 창 좌표가 어긋나는 이유

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는 통합 논리 좌표를 주므로 이 변환이 없다. 플랫폼 차이를 억지로 감추지 않는다
  • 오차가 좌표에 비례하면 배율, 일정하면 원점이다

보조 모니터를 안 쓰고 개발하면 이 버그는 사용자가 먼저 찾는다.

이 블로그 더 보기

이 블로그는 실제 프로젝트를 AI와 함께 굴리면서 남긴 기록입니다.

  • 새 글은 RSS로 받아볼 수 있습니다.
  • 시리즈 전체는 여기에 정리해 두었습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다