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()로 정리한다 - 추정으로 고친 것은 추정이라고 기록에 남긴다. “고쳤음”으로 적으면 재발했을 때 여기를 안 본다
- 증거가 없을수록 부작용이 작은 수정을 고른다
재현 안 되는 버그에서 제일 위험한 건 고치는 게 아니라, 고쳤다고 확신하는 것이다.