게임을 100시간 동안 꺼지 않고 켜 두면 화면은 여전히 초당 60번 그려지는데, 애니메이션 시간은 31.25ms 단위로만 앞으로 갑니다. 프레임 한 칸이 16.7ms니까 두 칸에 한 번꼴로만 움직이는 셈이죠. 직접 돌려 보니 100시간 지점에서는 프레임의 46.7%가 애니메이션 시간이 전혀 늘지 않은 채 지나가더라고요. 게임이 느려진 게 아니라 게임 속 시계의 눈금이 성겨진 결과였습니다.
게임 안에는 켜진 뒤 몇 초가 지났는지 세는 숫자가 하나 있습니다. 캐릭터가 위아래로 둥실거리는 것도, 깃발이 나부끼는 것도 대개 이 숫자를 sin 같은 식에 넣어서 위치를 뽑습니다. 이 숫자가 float32(소수를 32비트로 저장하는 방식)라면 켜 둔 시간이 길어질수록 숫자가 거칠어집니다. 얼마나 켜 둬야 어떻게 되는지 궁금해서 시뮬레이션으로 확인해봤습니다. 아래에서 자세히 알아보시죠.
게임 속 시계는 숫자 한 개다
유니티는 경과 시간을 float으로 주는 Time.time 외에 double로 주는 Time.timeAsDouble과 Time.realtimeSinceStartupAsDouble을 따로 두고 있는데요. 스크립팅 매뉴얼을 보면 오래 실행할 때 정밀도를 더 얻으려는 용도로 읽히게 됩니다. 일부러 같은 값을 두 벌 만들어 놨다는 건 float만으로는 모자라는 상황이 있다고 봤다는 얘기일 겁니다.
float32는 숫자를 대략 일곱 자리 정도만 기억하는데요. 0.5초라면 소수점 아래를 꽤 깊이 담지만, 360,000초(100시간)는 앞자리만 여섯 개라 소수 쪽에 남는 자리가 거의 없습니다.
시간이 길수록 눈금이 성겨진다
자로 길이를 잰다고 가정해봅시다. 짧은 구간에서는 0.1mm 눈금자를 쓰다가 구간이 길어지면 눈금이 1cm짜리 자로 바뀐다고 생각하면 됩니다. 값은 눈금 위에만 올라갈 수 있어서, 그 사이의 값은 가까운 눈금으로 옮겨 붙게 됩니다.
이 눈금의 간격을 시간으로 바꿔 보면 1시간 지점에서 0.244ms, 10시간에서 3.91ms, 50시간에서 15.6ms, 100시간에서 31.25ms, 1000시간에서 250ms였는데요. 눈금은 숫자가 두 배로 커질 때마다 한 번씩 굵어지는 것을 확인할 수 있었습니다. 그래서 9.1시간, 18.2시간, 36.4시간, 72.8시간, 145.6시간처럼 띄엄띄엄 바뀝니다. 프레임 한 칸이 16.7ms니까 50시간 근처에서 눈금 하나가 프레임 한 칸과 비슷해지고, 그 뒤로는 눈금이 프레임보다 굵어집니다.
10시간은 멀쩡, 100시간은 끊김
실험은 매우 단순하게 설정했습니다. 게임을 1, 10, 50, 100, 1000시간 켜 둔 순간을 고르고, 그 시각부터 60프레임으로 2초(120프레임) 동안 시간이 흐르게 한 겁니다. 진짜 시각은 float64로 계산한 다음 게임이 읽는 값처럼 float32로 내렸습니다. 시작 시각은 기준 시간에서 10% 범위 안으로 6만 3천 개를 무작위로 뽑았고, 시드를 세 가지로 바꿔도 비율은 같았습니다. 참고로, 프레임이 60fps로 딱 고르게 지나간다는 건 제가 둔 가정입니다.
1시간과 10시간에서는 120프레임 모두 시간이 정상으로 늘었는데요. 10시간쯤부터는 한 프레임의 증가량이 15.6ms와 19.5ms 사이에서 오르내렸는데, 이 정도가 눈에 보이는지는 재지 않았습니다. 50시간에서는 프레임 15개 중 하나(6.7%)가 31.25ms를 한 번에 건너뛰었어요. 눈금이 15.6ms인데 프레임 간격이 16.7ms라서, 15번 가면 한 칸이 남아 한꺼번에 튀어 나가는 식이었죠.
100시간부터는 모습이 달라졌습니다. 눈금 31.25ms에 프레임 16.7ms를 맞추면 15프레임 중 8번만 시계가 전진하고 7번은 제자리였다. 1 − 16.7÷31.25를 계산하면 46.7%라서 실측과 같았습니다. 시계가 움직이는 프레임만 세면 초당 32번, 1000시간에서는 눈금이 250ms라 초당 4번이었죠. 1Hz로 흔들리는 움직임의 위치 오차는 진폭을 1로 봤을 때 평균적인 오차(RMS)가 1시간 0.03%, 10시간 0.50%, 50시간 2.0%, 100시간 4.0%, 1000시간 31.6%였고, 100시간의 최댓값은 9.8%, 1000시간은 70.7%였습니다. 진폭 10cm짜리 흔들림이라면 100시간 뒤 평균 4mm, 1000시간 뒤 평균 3.2cm가 어긋난다는 뜻입니다.
더해 가는 방식이 먼저 망가졌다
시계를 세는 방법이 하나 더 있습니다. 매 프레임 t += dt로 float 변수에 계속 더해 가는 방식입니다. 이쪽도 같은 조건으로 600프레임을 돌려 봤는데 결과가 더 일찍, 더 이상하게 나오더라고요.
1시간 근처에서는 시계가 정상보다 0.4% 느리게 흘렀습니다. 9.1시간을 넘기자 속도가 0.9375배로 내려갔고 72.8시간까지 그대로였습니다. 더하려는 16.7ms를 눈금 3.9ms에 맞추면 4.27칸이라서 4칸(15.6ms)으로 반올림되기 때문입니다. 72.8시간부터는 눈금이 31.25ms가 되어 16.7ms가 0.53칸으로 계산되고, 반올림하면 1칸이라 프레임마다 31.25ms가 더해졌습니다. 시계가 오히려 1.875배로 달린 것이죠. 그리고 2의 19제곱초인 524,288초, 약 145.6시간(6일 남짓)을 지나자 눈금이 62.5ms로 벌어져 16.7ms가 0.27칸으로 계산되고 0칸으로 버려져서 시계가 완전히 멈춰버렸습니다.
제 눈에는 1.875라는 숫자가 가장 이상하게 느껴졌습니다. 눈금이 성겨지면 느려지거나 멈출 줄 알았는데, 한 구간에서는 거꾸로 두 배 가까이 빨라졌기 때문입니다. 결과는 그림 하나로 합쳤습니다. 아래 사진 상에서 왼쪽은 눈금 간격, 가운데는 프레임마다 시간이 늘어난 모습(파랑 정상, 초록 건너뜀, 주황 멈춤), 오른쪽은 더해 가는 방식의 시계 속도입니다.

눈에 보일까, 그리고 흔할까
화면으로 봤을 때 티가 나는지는 따로 보지 못했습니다. 숫자만 보면 50시간까지는 15프레임에 한 번 튀는 정도고, 눈에 확연하게 보일 만한 건 100시간 근처부터일 것 같지만 짐작일 지나지 않습니다. 100시간은 4일 조금 넘는 시간이고 1000시간은 41일 남짓입니다. 평범한 플레이에서는 만날 일이 없고, 전시용 PC나 키오스크, 절전 모드로 계속 켜 두는 기기에서나 나올 만한 시간이죠. 하지만, 실제로 그런 환경이 얼마나 흔한지는 확인하지 못했습니다.
이 실험은 게임 전체가 경과 시간 하나를 공유하는 경우만 다뤘습니다. 애니메이션마다 자기 타이머를 0부터 세는 구조라면 이 문제를 비켜 갈 수 있을 것입니다. 엔진별로 시간을 어떻게 세고 어떻게 넘겨주는지도 유니티 매뉴얼 말고는 따져 보지 못했습니다.
해결은 의외로 간단했다
1초 주기로 흔들리는 움직임이라면 시간을 1초로 나눈 나머지만 쓰면 됩니다. 정확한 시계는 float64로 두고 계산하기 직전에 나머지만 float32로 내리는 식입니다. 같은 조건으로 돌렸더니 1시간부터 1000시간까지 평균 오차(RMS)가 모두 진폭의 0.000006% 안팎이었고 최댓값도 0.00002%를 넘지 않았습니다. float64의 눈금은 1000시간 지점에서도 0.0000005ms(약 0.5나노초) 정도라서 시계 자체는 사실상 문제가 없었습니다. 주기가 1초가 아닌 움직임이나 여러 주기가 섞인 경우는 이번 실험에 넣지 못했습니다.
처음의 46.7%는 게임이 느려져서 나온 숫자가 아니었습니다. 눈금 31.25ms를 프레임 16.7ms로 나눴을 때 남는 비율이었고, 시계를 정확하게 세어 두면 눈금은 다시 촘촘해졌습니다.