원점에서 멀어지면 떨리는 이유
Esc

    원점에서 멀어지면 떨리는 이유

    –Unity기초

    큰 맵을 만들어 원점에서 수십 km 떨어진 곳까지 가 보면, 가만히 서 있는 캐릭터가 덜덜 떨리고 카메라가 계단처럼 움직입니다. 천천히 걸으면 아예 움직이지 않다가, 조금 빨리 걸으면 이번에는 생각보다 빨리 갑니다.

    유니티의 위치는 float 라는 숫자 형식에 담기고, float 는 큰 수일수록 담을 수 있는 값 사이의 간격이 넓어집니다. 1m 근처에서는 0.00000012m 단위까지 셀 수 있지만, 100,000m 근처에서는 0.0078m 단위로만 셀 수 있습니다. 그 사이의 값은 담을 수 없어서 가장 가까운 칸으로 반올림됩니다.

    float 는 담을 수 있는 숫자 칸 수가 정해져 있어서, 값이 2 배 커질 때마다 칸의 간격도 2 배 넓어집니다. 100,000m 에서는 칸이 7.8mm 라, 한 프레임에 3mm 가는 움직임은 사라지고 5mm 는 7.8mm 로 부풉니다. 플레이어가 멀리 가면 세상 전체를 원점 쪽으로 옮겨 다시 원점 근처에서 계산합니다(floating origin).

    자릿수가 정해진 계산기

    화면에 숫자를 딱 7개만 띄울 수 있는 계산기가 있다고 합시다. 소수점은 숫자 사이 아무 데나 찍을 수 있습니다.

    넣은 값 7칸에 담긴 모습 소수점 아래 자리 바로 옆 값까지의 간격
    1.234567 1.234567 6자리 0.000001
    1,234.567 1234.567 3자리 0.001
    123,456.7 123456.7 1자리 0.1
    1,234,567 1234567 0자리 1

    수가 커지면 앞자리가 칸을 먹어서, 소수점 아래에 남는 칸이 줄어듭니다. 1 근처에서는 0.000001 까지 구분하던 계산기가 100만 근처에서는 1 단위로만 셉니다. 칸에 안 들어가는 값은 반올림합니다. 반올림은 가장 가까운 담을 수 있는 값으로 맞추는 것입니다. 123,456.78 은 8자리라 7칸에 안 들어가니, 가까운 123456.8 로 담깁니다. 0.02 가 버려졌습니다.

    이 계산기가 담는 것을 둘로 나눠 볼 수 있습니다.

    이름 무엇 123,456.7 이면
    유효 숫자 실제로 담긴 숫자들. 칸 수가 정해져 있음 1234567 (7자리)
    지수 소수점을 몇 자리 옮겼나. 10⁵ 는 10 을 다섯 번 곱한 100,000 1.234567 × 10⁵ 의 5

    숫자 7개는 그대로 두고 지수만 바꿔 아주 작은 수부터 아주 큰 수까지 담는 방식을 부동소수점(소수점이 떠다닌다)이라고 부릅니다. float 가 이 방식입니다.

    float 는 2 배마다 칸이 2 배 넓어진다

    실제 float 는 10진 숫자 대신 0 과 1 로 된 2진 숫자를 담습니다. 32비트(0 과 1 서른두 개) 안에 유효 숫자 24자리와 지수를 넣습니다. 2진수라서 칸 수가 같은 구간이 10 배 단위가 아니라 2 배 단위입니다. 12, 24, 4~8 … 처럼 2 배씩 커지는 구간마다 똑같이 8,388,608 칸(2를 23번 곱한 수)으로 나눕니다. 구간이 두 배로 길어지는데 칸 수는 같으니, 칸 하나의 폭이 두 배가 됩니다.

    칸이 8백만 개면 눈으로 볼 수 없으니, 한 구간을 4칸으로만 나누는 작은 float 를 그렸습니다.

    같은 일이 실제 float 에서 8,388,608 칸으로 일어납니다. 간격 = 구간 길이 ÷ 8,388,608 입니다.

    위치 (m) 들어 있는 2 배 구간 간격 길이로 치면
    1 1 ~ 2 1 ÷ 8,388,608 = 0.00000012 0.0001mm
    1,000 512 ~ 1,024 512 ÷ 8,388,608 = 0.000061 0.06mm
    10,000 8,192 ~ 16,384 8,192 ÷ 8,388,608 = 0.00098 1mm
    100,000 65,536 ~ 131,072 65,536 ÷ 8,388,608 = 0.0078 7.8mm
    1,000,000 524,288 ~ 1,048,576 524,288 ÷ 8,388,608 = 0.0625 6cm

    한 구간을 838만 칸으로 나누면 10진 숫자 7개쯤의 정밀도라, float 를 흔히 유효 숫자 약 7자리라고 부릅니다. 7칸 계산기와 숫자가 똑같지는 않지만(계산기는 10 배마다, float 는 2 배마다 칸이 넓어집니다), 수가 클수록 칸이 넓다는 것은 같습니다. 1,000m 까지는 0.06mm 라 눈에 띌 일이 없지만, 100km 에서는 한 칸이 7.8mm, 1,000km 에서는 6cm 입니다.

    작은 움직임이 사라지거나 부푼다

    position.x += 이동량 을 하면 더한 결과가 가장 가까운 칸으로 반올림되어 저장됩니다. 이동량이 칸 간격의 절반보다 작으면 원래 자리로 반올림되어 움직임이 사라지고, 절반보다 크면 한 칸 통째로 가서 부풉니다.

    100,000m 에서 칸은 7.8mm, 절반은 3.9mm 입니다. 한 프레임에 5mm 와 3mm 씩 가는 경우를 원점 근처와 비교합니다. 가로 한 칸이 1mm 이고, 경고색 점선이 100,000m 에서 float 가 가질 수 있는 값들입니다.

    한 프레임 3mm 는 60fps(1초에 60장)에서 1초에 0.18m, 5mm 는 0.3m 입니다. 천천히 걷는 속도입니다. 같은 코드가 원점 근처에서는 고르게, 100km 에서는 멈추거나 부풀게 움직입니다. 프레임마다 이동량이 조금씩 달라 반올림 방향이 바뀌면, 그것이 떨림으로 보입니다.

    위치만의 문제가 아니다

    반올림은 위치를 쓰는 모든 계산에 따라 들어갑니다.

    어디서 보이는 것
    캐릭터 이동 천천히 걸으면 멈추고, 조금 빠르면 계단처럼 감
    카메라 따라가는 카메라가 덜덜 떨림. 카메라와 대상이 따로 반올림되어 서로 어긋남
    렌더링 메시의 꼭짓점 위치가 칸에 맞춰져 모양이 출렁임
    물리 접촉점이 흔들려 가만히 놓인 물체가 떨리거나 미끄러짐
    애니메이션 손발 위치가 칸에 맞춰져 관절이 떨림

    두 큰 수를 빼서 작은 차이를 구하는 계산이 특히 약합니다. 100,000m 근처에 두 캐릭터가 실제로 5mm 떨어져 서 있다고 합시다. 둘의 위치는 7.8mm 칸으로 반올림되어 저장되니, 빼서 구한 거리는 0 이나 7.8mm 둘 중 하나만 나옵니다. 5mm 라는 답은 나올 수 없습니다. 카메라에서 물체까지의 방향, 두 캐릭터 사이의 거리 같은 값이 모두 이런 뺄셈입니다.

    세상을 원점 쪽으로 옮긴다: floating origin

    float 의 칸 수를 늘릴 수는 없으니, 계산하는 숫자를 작게 유지합니다. 플레이어가 원점에서 일정 거리(예: 5km, 칸이 0.5mm 인 곳)를 벗어나면, 플레이어를 포함한 모든 오브젝트를 같은 만큼 반대로 옮겨 플레이어를 다시 원점 근처로 데려옵니다. 모두 같이 옮기니 서로의 상대 위치는 그대로이고, 플레이어 눈에는 아무 일도 일어나지 않습니다. 이 방식을 floating origin 이라고 부릅니다. 정해진 거리마다 한 번에 옮기는 것을 따로 origin shifting 이라고도 합니다.

    플레이어가 x = 5,200 에 왔다면 모두를 x 로 −5,200 옮깁니다. 플레이어는 0 으로, 플레이어 앞 10m 에 있던 나무는 5,210 에서 10 으로 갑니다. 나무는 여전히 플레이어 앞 10m 입니다.

    public Transform player;
    public float threshold = 5000f;   // 원점에서 5km 를 넘으면
    
    // 세상이 원점에서 얼마나 옮겨졌는지. float 보다 칸이 훨씬 많은 double 로 들고 있는다
    double worldX;
    double worldZ;
    
    void LateUpdate()
    {
        Vector3 offset = player.position;
        offset.y = 0f;   // 보통 높이는 그대로 둔다
    
        if (offset.magnitude < threshold)
        {
            return;
        }
    
        // 루트 오브젝트(부모가 없는 것)를 전부 같은 만큼 반대로 옮긴다. 자식은 부모를 따라간다
        foreach (GameObject root in gameObject.scene.GetRootGameObjects())
        {
            root.transform.position -= offset;
        }
    
        // 옮긴 Transform 을 물리 엔진에도 바로 알린다
        Physics.SyncTransforms();
    
        worldX += offset.x;
        worldZ += offset.z;
    }

    offset.magnitude 는 원점에서 플레이어까지의 거리(화살표의 길이)입니다. GetRootGameObjects 는 이 스크립트가 있는 씬 하나만 돌려주니, 씬을 여러 개 띄운다면 씬마다 옮깁니다. 실제로 쓸 때는 챙길 것이 더 있습니다.

    챙길 것 이유
    파티클, 트레일 월드 공간에 남긴 흔적은 오브젝트와 따로 저장되어 있어 따로 옮겨야 함
    물리 위치만 옮기고 속도는 그대로 둠. 물리 걸음 사이에 한 번에 옮김
    카메라 Cinemachine 이면 대상이 순간이동했다고 알려야(OnTargetObjectWarped) 카메라가 그 사이를 미끄러지지 않음
    저장, 네트워크 화면 좌표가 아니라 worldX, worldZ 를 더한 진짜 좌표를 씀
    내비메시, 정적 배칭 처음 자리에 맞춰 미리 만들어 둔 데이터라 옮기면 어긋남. 따로 다시 맞춰야 함

    double 은 float 의 두 배인 64비트 숫자 형식입니다. 유효 숫자가 2진 53자리, 10진으로 약 15자리라 지구 둘레(약 40,000km)만 한 값에서도 칸 간격이 0.0000000075m 입니다. 유니티의 Transform 은 float 만 받으니, 게임 로직의 진짜 좌표는 double 로 따로 들고, 화면에 그릴 때만 원점 기준으로 빼서 float 로 넘기는 것이 큰 세계를 다루는 기본 방식입니다.

    얼마나 멀면 문제가 되나

    유니티 에디터도 위치의 x · y · z 중 하나가 100,000 을 넘으면 Transform Inspector 에 경고를 띄웁니다. “부동소수점 정밀도 한계 때문에 월드 좌표를 더 작은 범위로 가져오기를 권한다” 는 내용이고, 칸이 7.8mm 가 되는 자리입니다.

    원점에서 칸 간격 대략
    1km 안 0.06mm 이하 걱정 없음
    10km 1mm 가까이 본 카메라, 정밀한 애니메이션에서 보이기 시작
    100km 7.8mm 걷기 · 카메라 떨림이 뚜렷함. 에디터 경고
    1,000km 6cm 정상적인 플레이 불가

    맵이 수 km 안이면 신경 쓰지 않아도 됩니다. 오픈월드, 비행, 우주처럼 수십 km 를 넘는 게임이면 처음부터 floating origin 을 계획에 넣습니다. 나중에 넣으면 위치를 저장하고 주고받는 모든 코드를 진짜 좌표로 고쳐야 해서 일이 커집니다.

    정리

    • 유니티의 위치는 float 입니다. float 는 유효 숫자 칸 수가 정해져 있고 지수로 소수점을 옮기는 부동소수점 형식입니다
    • 2 배 구간마다 같은 8,388,608 칸으로 나누니, 값이 2 배가 될 때마다 칸의 간격도 2 배가 됩니다. 100km 에서 7.8mm, 1,000km 에서 6cm 입니다
    • 더한 값은 가장 가까운 칸으로 반올림되어, 칸의 절반보다 작은 움직임은 사라지고 큰 움직임은 한 칸으로 부풉니다
    • 큰 두 수를 빼서 작은 차이를 구하는 계산(방향, 거리)이 특히 거칠어집니다
    • 플레이어가 멀어지면 모든 오브젝트를 같은 만큼 옮겨 원점 근처로 되돌리는 floating origin 으로 막습니다
    • 진짜 좌표는 double 로 따로 들고, 그릴 때만 원점 기준으로 바꿔 float 로 씁니다