콘텐츠로 건너뛰기
Codex

큰 스프라이트 아틀라스에서 가끔 엉뚱한 그림이 나올 때

TECH NOTE · AUTOMATION OPS

큰 아틀라스에서 멀리 떨어진 영역을 자주 자르면 드물게 엉뚱한 그림이 나옵니다. 프레임을 미리 잘라 두는 대응과 추정을 기록하는 법입니다.

아주 가끔 다른 자세가 한 프레임 스친다

바탕화면 캐릭터에서 잘못된 그림이 순간 번쩍이는 증상이 있었다. 타이머 콜백이 겹치는 문제를 고쳤더니 크게 줄었는데, 완전히 사라지지는 않았다.

빈도가 훨씬 낮아져서 재현이 더 어려워졌다. 한 시간에 한두 번쯤.

남은 원인을 찾다가 그리는 쪽을 보게 됐다.

한 장짜리 큰 텍스처에서 잘라 쓰고 있었다

스프라이트를 이렇게 그리고 있었다. 아틀라스 하나를 이미지로 올려 두고, 매 프레임 필요한 영역만 잘라서 캔버스에 그린다.

ctx.drawImage(
  atlas,                       // 2048 × 17920 짜리 한 장
  cellX, cellY, cellW, cellH,  // 원본에서 잘라 낼 영역
  destX, destY, destW, destH,  // 캔버스에 그릴 위치
);

일반적인 방식이고 대개 잘 동작한다. 문제는 이 아틀라스의 크기와 접근 패턴이었다.

아틀라스: 2048 × 17920
idle:               y = 0
corner_crouch_sad:  y = 14336

세로로 아주 길고, 상태가 바뀔 때마다 멀리 떨어진 영역을 오간다. 0번 줄을 읽다가 다음 프레임에 14336번 줄을 읽는다.

큰 공유 텍스처에서 멀리 떨어진 영역을 자주 샘플링하면, 엔진에 따라 가끔 엉뚱한 영역을 읽는 것으로 알려져 있다. 타일 캐시나 텍스처 업로드 타이밍과 얽힌 문제다.

확실한 증거는 없었다. 재현이 안 되니 원인을 특정할 수 없었다. 다만 이미 다른 원인을 하나 제거한 뒤에도 남은 증상이었고, 접근 패턴이 정확히 그 조건에 해당했다.

미리 잘라 두면 이 조건이 사라진다

원인을 확정할 수 없으면 조건을 없애는 쪽이 낫다. 매 프레임 큰 텍스처를 자르는 대신, 로드할 때 프레임마다 작은 이미지를 만들어 둔다.

const cache = new Map();

for (const [state, render] of renderStates) {
  for (let frame = 0; frame < render.frames.length; frame += 1) {
    const rect = render.frames[frame];
    const bitmap = await createImageBitmap(
      atlas, rect.x, rect.y, rect.w, rect.h,
    );
    cache.set(`${state}:${frame}`, bitmap);
  }
}

createImageBitmap은 원본에서 영역을 잘라 독립된 비트맵을 만든다. 이후 그릴 때는 이 작은 비트맵을 통째로 쓴다.

const bitmap = cache.get(`${state}:${frame}`);
if (bitmap) {
  ctx.drawImage(bitmap, destX, destY, destW, destH);
} else {
  ctx.drawImage(atlas, cellX, cellY, cellW, cellH, destX, destY, destW, destH);
}

원본 자르기 경로를 폴백으로 남겼다. 캐시가 다 만들어지기 전(로드 직후, 팩 교체 직후)에는 그림이 아예 안 나오면 안 되기 때문이다.

미리 만드는 비용

이 방식의 대가는 메모리와 초기화 시간이다.

  • 프레임 수만큼 비트맵 객체가 생긴다
  • 로드할 때 그만큼 시간이 걸린다
  • 캐릭터 팩을 바꾸면 전부 다시 만들어야 한다

이 프로젝트에서는 감당할 만했다. 상태 35개에 프레임이 상태당 4~12개라 200개 남짓이고, 셀 하나가 512×512다.

아틀라스 전체를 이미 메모리에 올려 두고 있었다는 점도 중요하다. 잘라 둔 비트맵의 총량은 아틀라스와 비슷하다. 두 배가 되지만 원래 한 장이 큰 게 아니었다.

프레임이 수천 개라면 이 방식이 안 맞는다. 그때는 필요한 것만 잘라 두고 LRU로 관리하거나, 아틀라스를 여러 장으로 나눠 접근 거리를 줄이는 쪽이 낫다.

교체할 때 정리해야 한다

ImageBitmap은 명시적으로 해제할 수 있다.

function clearFrameCache(cache) {
  for (const bitmap of cache.values()) {
    bitmap.close();
  }
  cache.clear();
}

close()를 안 불러도 결국 회수되지만, 캐릭터 팩을 여러 번 바꾸면 그동안 메모리가 쌓인다. 교체 경로에서 정리하는 편이 안전하다.

확정 못 한 원인을 다루는 법

이 수정에는 찜찜한 구석이 있다. 원인을 증명하지 못했다.

커밋 메시지에도 그렇게 적었다.

Large shared textures with frequent far-apart source-rect sampling are
a known source of occasional wrong-region reads in some engines, which
plausibly explains a wrong-pose flash reported after the tickLocomotion
race was already fixed.

plausibly explains — 그럴듯하게 설명된다. 확정이 아니다.

이런 수정을 할 때 지켜야 할 게 있다고 본다.

추정임을 기록에 남긴다. 나중에 같은 증상이 또 나면, 이 수정이 원인을 잡은 게 아니었다는 걸 알아야 한다. “고쳤음”으로 적어 두면 다음 사람이 여기를 안 본다.

부작용이 작은 쪽을 고른다. 프레임을 미리 자르는 건 성능에도 나쁘지 않고, 틀렸어도 손해가 거의 없다. 증거 없이 큰 구조를 바꾸는 것과는 다르다.

이미 제거한 원인을 명시한다. “타이머 경합을 고친 뒤에도 남은 증상”이라고 적어 두면, 무엇이 배제됐는지가 남는다.

정리

  • 큰 아틀라스에서 멀리 떨어진 영역을 자주 자르면 드물게 엉뚱한 영역을 읽을 수 있다
  • 원인을 특정할 수 없으면 조건을 없앤다. 프레임마다 createImageBitmap으로 미리 잘라 둔다
  • 캐시가 준비되기 전을 위해 원본 자르기 폴백을 남긴다
  • 비용은 메모리와 초기화 시간. 프레임 수가 수백이면 감당되고 수천이면 다른 방법이 필요하다
  • 에셋 교체 경로에서 close()로 정리한다
  • 추정으로 고친 것은 추정이라고 기록에 남긴다. “고쳤음”으로 적으면 재발했을 때 여기를 안 본다
  • 증거가 없을수록 부작용이 작은 수정을 고른다

재현 안 되는 버그에서 제일 위험한 건 고치는 게 아니라, 고쳤다고 확신하는 것이다.

이 블로그 더 보기

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

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

답글 남기기

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