dotgimon 제작기 18. 리셋 오류와 입력 지연, LCD 화면 개선
dotgimon의 리셋을 누르자 오류 화면이 떴다. 펫을 지우고 새 알을 보여 주는 동작인데, 그림을 지우는 도중에 멈췄다. 리셋을 고치고 나서는 버튼 입력이 늦어지는 문제를 다시 살폈고, 메뉴를 직접 누르며 도감과 청소 연출까지 손봤다.
옛 그림을 너무 일찍 지우는 건 아닐까
처음 나온 오류는 Attempted to access a disposed object였다. 그 뒤로 Should not already be working.도 반복됐다. 스택에서 saveLayer와 Picture를 읽는 경로를 확인했다.
Picture는 Skia의 그리기 명령을 기록한 객체다. 게임은 LCD 도트를 새 Picture로 만든 뒤 Reanimated의 SharedValue에 넣어 Canvas가 그리게 했다. SharedValue는 JS와 UI 런타임 사이에서 값을 공유하는 장치다.
오래된 Picture를 계속 두면 네이티브 메모리가 쌓이므로 몇 장만 보관하고 dispose()로 해제했다. 문제를 의심한 코드는 그림을 교체하는 부분이었다.
const RETIRED_PICTURE_LIMIT = 4
function replacePicture(target: SharedValue<SkPicture>, next: SkPicture, retired: SkPicture[]) {
const previous = target.value
target.value = next
retired.push(previous)
if (retired.length > RETIRED_PICTURE_LIMIT) retired.shift()?.dispose()
}JS에서 SharedValue에 쓴 값은 UI 쪽에 비동기로 적용된다. Reanimated 문서에도 이 차이가 설명돼 있다. 새 그림을 넣었다고 해서 UI가 이미 옛 그림을 놓았다고 볼 수는 없었다.
리셋 중에는 화면 재녹화가 몰렸다. UI에 옛 Picture가 남은 동안 JS에서 해제되는 경쟁을 원인으로 의심했다. 교체와 보관, 해제를 모두 UI 런타임으로 옮기고 보관 목록도 SharedValue에 넣었다.
replacePicture는 직접 해제하는 대신 UI 작업을 예약하게 바꿨다.
function swapPictureOnUI(target, next, retired) {
'worklet'
const previous = target.value
target.value = next
const list = [...retired.value, previous]
while (list.length > RETIRED_PICTURE_LIMIT) list.shift()?.dispose()
retired.value = list
}
export function replacePicture(target, next, retired) {
scheduleOnUI(swapPictureOnUI, target, next, retired)
}같은 작업 안에서 교체한 뒤 오래된 그림을 해제하도록 실행 순서를 맞췄다. PictureRecorder는 녹화를 끝낸 직후 해제하고 있어서 그대로 뒀다.
수정 뒤에도 리셋 화면이 검게 남았다. 정상 연출은 올리브색으로 잠시 덮였다가 새 알이 나오는 것이었다. Fast Refresh로 모듈만 바꾼 상태에서는 확인이 되지 않아 앱을 완전히 종료하고 다시 열었다.
시뮬레이터에서 리셋을 한 번, 이어서 다섯 번 실행했을 때는 암전 뒤 새 알이 나왔고 오류가 기록되지 않았다. Picture 경쟁은 수정 방향을 정한 가설이었다. 뒤따른 reconciler 오류와 검은 화면이 내부에서 정확히 어떻게 이어졌는지는 확인하지 못했다.
버튼은 먹통이 아니라 늦게 반응했다
앱을 오래 켜 두니 버튼이 다시 안 눌리는 것처럼 보였다. 진단용 원격 명령으로 game.press()를 부르면 입력은 들어갔다. 게임의 입력 함수가 막혔다기보다 앱 전체가 느려진 쪽에 가까웠다.
JS 힙이 커지고 쓰지 않는 메모리를 정리하는 GC가 반복되는 동안 게임 루프도 느려졌다. 리셋 수정 때문인지 가리려고 같은 조건에서 화면 갱신을 하나씩 껐다.
| 끈 부분 | 관찰 |
|---|---|
| UI 런타임으로 Picture를 보내는 수정 | 힙이 계속 증가했다 |
| SharedValue에 LCD 그림 쓰기 | 힙이 계속 증가했다 |
몬스터 위치의 setOverlays | 힙 증가가 멎었다 |
| Skia Canvas | 힙이 계속 증가했다 |
| 개발 모드 대신 production 실행 | 힙이 계속 증가했다 |
setOverlays를 따라가니 몬스터가 걷는 동안 Device 전체가 계속 다시 렌더링되고 있었다. overlays와 menu를 Device가 호출하는 useGame의 React 상태로 들고 있었기 때문이다. LCD만 바뀌어도 바깥 셸과 스타일을 포함한 트리가 함께 실행됐다.
어느 객체가 메모리를 붙잡았는지까지는 찾지 못했다. Hermes 힙 스냅샷은 디버거 연결이 끊겨 얻지 못했다. 그래서 누수 객체를 고쳤다고 할 수는 없었다. 반복 실행 범위를 줄이는 쪽으로 수정했다.
움직이는 값은 LCD만 구독하게 했다
프레임마다 바뀌는 값은 React 상태에서 빼고 작은 외부 스토어에 넣었다. 바뀐 스냅샷과 구독 함수를 제공하고, 게임 루프는 그 값만 갱신하게 했다.
스냅샷은 set이 불릴 때 새 객체로 만들고, 그전까지는 같은 객체를 반환했다. useSyncExternalStore는 이런 외부 스토어를 React에서 구독하는 훅이다.
Lcd에만 구독을 뒀다. 화면 바깥의 Device는 매 프레임 바뀌는 값을 더 이상 React 상태로 받지 않았다.
const { overlays, menu } = useSyncExternalStore(
game.lcdFrame.subscribe,
game.lcdFrame.get,
)시뮬레이터 관찰 구간에서는 Device의 반복 렌더링이 줄고 힙 증가와 입력 지연도 크게 줄었다. LCD 갱신 주기는 유지했다. 어떤 객체가 새는지는 모르는 상태였으므로 원인을 완전히 제거했다는 결론은 내리지 않았다.
원격 명령만으로는 손가락에서 게임까지 오는 경로를 확인할 수 없었다. “개선된 거 맞지? 계속 버튼이 안 눌리는데”라고 다시 물은 뒤 터치 로그를 붙였다. A·B·C를 직접 눌렀을 때 버튼 이벤트가 게임 입력까지 전달되는지 확인했고, 시뮬레이터를 33분 켜 둔 뒤에도 A 입력으로 STATUS가 열렸다.
몬스터 이미지도 두 프레임 GIF에서 PNG 스트립으로 바꿨다. 프레임을 옆으로 붙여 두고 필요한 칸을 직접 고르게 했다. 변환한 프레임은 기존 이미지와 픽셀을 비교했다.
도감은 목표를 보여 주는 화면이었다
메뉴와 도감을 직접 누르며 보니 미발견 몬스터, 진화 관계선, 선택 표시, 버튼 라벨이 눈에 걸렸다. 처음 제안은 미발견 종을 실루엣과 ???로 가리는 것이었다.
한 마리만 키우는 게임에서 이름까지 전부 가리면 다음에 무엇을 키우고 싶은지도 알기 어려웠다. 도감에서 목표는 보여 주고 발견 보상은 따로 두는 쪽을 골랐다. 도트와 이름은 공개하되 미발견 종은 흐리게, 발견한 종에는 테두리와 GET 표시를 붙였다. 컬러 일러스트만 발견 보상으로 잠갔다.
선택 커서는 깜빡임이 꺼진 순간 아예 안 보였다. 반전 박스와 테두리를 써서 어느 위상에서도 선택한 항목이 남게 했다. 깜빡임 전후 화면을 비교하는 테스트도 넣었다.
하단 안내 문구를 맞추고, 대전이나 합체에 들어갈 수 없는 이유를 선택 화면에서 보여 줬다. 소등 화면에는 수면 여부를 나타내는 그림을 넣었고 버튼 아래에는 A·B·C를 표시했다. 거절 사유가 다른 화면에 남는 문제는 일반 메시지와 별도 필드로 나눠 고쳤다.
VITA 아이콘이 MEAT와 비슷하다는 지적도 있었지만 그 모양은 유지했다. 자동 조작으로 화면을 검토하는 동안 먹이와 훈련이 실제 세이브에 반영된 것도 확인했다.
빗자루 대신 몬스터까지 씻겨 나갔다
청소 장면을 녹화해 보니 빗자루는 막대처럼 보였고, 똥은 닿자마자 사라졌다. 메뉴 아이콘도 함께 없어졌고 연출은 짧았다. 빗자루를 크게 다시 그렸지만 원하는 모양이 아니어서 여러 후보를 비교했다.
결국 빗자루 없이 물결 띠가 화면을 쓸어 가는 쪽을 골랐다. 처음에는 오른쪽에서 왼쪽으로 흘렀다. 방향을 반대로 바꾸고 몬스터도 똥과 함께 떠내려가게 해 달라고 요청했다.
평소 LCD는 몬스터가 화면 밖으로 나가지 않게 붙잡는다. 청소 중에는 그 제한을 풀도록 스프라이트에 free 표시를 추가했다. 그러자 물결에 실린 몬스터를 화면 밖까지 보낼 수 있었다.
복귀할 때 반대편에서 미끄러져 들어오는 모습도 바꿨다. 물결과 몬스터가 완전히 사라진 뒤 잠깐 빈 화면을 두고 중앙에 나타나게 했다. 전체 길이는 2초로 맞췄다.
똥이 없을 때도 청소를 누를 수 있게 했지만 게임 상태와 똥 생성 기준 시각은 바꾸지 않았다. 빈 청소로 다음 똥을 늦출 수 없게 테스트로 확인했다. 여러 번 그렸던 빗자루는 쓰지 않아 지웠다.
작업본에는 입력 지연을 살피려고 넣은 진단 비콘도 남아 있었다. 주기적으로 로컬 주소에 상태를 보내고 응답 명령으로 버튼을 누르는 코드였고, 개발 빌드로 제한되지 않았다. 화면이 정상으로 돌아온 것과 진단 코드까지 정리된 것은 별개였다.