콘텐츠로 건너뛰기
Codex

스프라이트 시트 규격 정하기

스프라이트 시트 규격 정하기

TECH NOTE · DESKTOP AI PET

스프라이트 시트를 행·열 계산으로 읽으면 프레임이 한 행을 넘는 순간 조용히 틀립니다. 걷는 동작이 절반에서 튀는 원인과 명시 좌표로 바꾼 이유를 실제 매니페스트와 함께 정리했습니다.

걷는 애니메이션이 절반에서 처음으로 튄다

지난 편에서 창을 만들었다. 이번 편은 그 창 안에 무엇을 그릴지다.

캐릭터는 스프라이트 시트 한 장에 들어 있다. 왜 스프라이트 시트를 골랐는지는 앞선 글에 적었고, 이번 편은 그 시트를 어떻게 읽는지다.

처음 규칙은 단순했다. 행 하나가 상태 하나, 열이 프레임이다. 그러면 좌표는 곱셈으로 나온다.

x = 프레임번호 × 셀너비
y = 행번호 × 셀높이

idle은 행 0, walk는 행 1, jump는 행 2. 매니페스트에는 상태마다 행 번호와 프레임 수만 적으면 된다.

그러다 걷는 동작이 어색해서 프레임을 4장에서 8장으로 늘렸다. 한 행에는 4장밖에 안 들어간다.

이번 편의 코드다.

https://github.com/bluecafe/pet-reference/tree/main/sprite-sheet

시트를 다시 만들지 않았다

8프레임을 한 행에 넣으려면 시트 전체를 8열로 다시 짜야 한다. 그러면 4프레임짜리 상태 서른 개가 전부 절반을 비운 채로 남는다.

그래서 남는 행에 뒷부분을 이어 붙였다. walk의 앞 4프레임은 원래 자리에 두고, 뒤 4프레임은 시트 끝의 빈 행에 넣었다.

실제 앱의 매니페스트를 열어 보면 이렇게 되어 있다.

walk  0번  행 1   (y=512)
      3번  행 1
      4번  행 34  (y=17408)   ← 여기서 건너뛴다
      7번  행 34

시트가 36행인데 walk의 뒷부분이 34행에 있다. 중간 서른 몇 행을 건너뛴다.

계산식이 어디서 깨지나

곱셈으로 좌표를 구하는 코드는 이렇게 생겼다.

export function computeFrames(
  manifest: SpriteManifest,
  state: string,
): FrameRect[] | null {
  const playback = manifest.playback[state];
  if (!playback) return null;

  const { cellWidth, cellHeight } = manifest.geometry;
  return Array.from({ length: playback.frames }, (_, index) => ({
    x: (index % manifest.columns) * cellWidth,
    y: playback.row * cellHeight,
    w: cellWidth,
    h: cellHeight,
  }));
}

문제는 index % manifest.columns 한 곳이다. 열 수로 나눈 나머지를 쓰니 프레임 번호가 열 수를 넘는 순간 앞으로 돌아간다.

walk의 5번째 프레임(번호 4)을 넣어 보면 이렇게 된다.

x = (4 % 4) × 512 = 0
y = 1 × 512 = 512

1번째 프레임과 정확히 같은 자리다. yplayback.row만 보므로 행은 아예 바뀌지 않는다.

화면에서는 이렇게 보인다. 걷는 동작이 네 장까지 진행되다가 갑자기 첫 장으로 튀고, 다시 네 장을 반복한다. 8프레임을 그렸는데 4프레임만 도는 것처럼 보인다.

예제를 돌리면 계산과 실제가 갈라지는 지점이 그대로 나온다.

  idle        4프레임  같음
  walk        8프레임  다름  ← 계산으로 못 찾는다
  jump        4프레임  같음

전부 좌표를 적어 둔다

고치는 방법은 두 가지였다.

계산을 쓰되 예외를 적어 두기walk만 특별 취급한다. 코드는 적게 바뀌지만, 이후로 상태를 볼 때마다 “이건 계산이었나 예외였나”를 확인해야 한다. 예외가 하나에서 멈춘다는 보장도 없다.

전부 좌표를 적기 — 매니페스트가 길어진다. 대신 읽는 쪽은 한 가지 방법만 쓴다.

후자를 골랐다. 한 상태라도 계산과 어긋나면 계산을 믿을 수 없다.

그래서 매니페스트에 두 벌이 들어간다.

{
  "playback": {
    "idle": { "row": 0, "frames": 4, "fps": 3,  "loop": true },
    "walk": { "row": 1, "frames": 8, "fps": 8,  "loop": true }
  },
  "layout": {
    "idle": [
      { "x": 0,    "y": 0,   "w": 512, "h": 512 },
      { "x": 512,  "y": 0,   "w": 512, "h": 512 },
      { "x": 1024, "y": 0,   "w": 512, "h": 512 },
      { "x": 1536, "y": 0,   "w": 512, "h": 512 }
    ],
    "walk": [
      { "x": 0,    "y": 512, "w": 512, "h": 512 },
      { "x": 512,  "y": 512, "w": 512, "h": 512 },
      { "x": 1024, "y": 512, "w": 512, "h": 512 },
      { "x": 1536, "y": 512, "w": 512, "h": 512 },
      { "x": 0,    "y": 17408, "w": 512, "h": 512 },
      { "x": 512,  "y": 17408, "w": 512, "h": 512 },
      { "x": 1024, "y": 17408, "w": 512, "h": 512 },
      { "x": 1536, "y": 17408, "w": 512, "h": 512 }
    ]
  }
}

playback어떻게 재생할지, layout어디에 있는지다. walk의 다섯 번째 항목에서 y가 512에서 17408로 뛰는 것이 보인다. 계산으로는 나올 수 없는 값이다.

읽는 쪽은 layout만 본다.

export function resolveFrames(
  manifest: SpriteManifest,
  state: string,
): readonly FrameRect[] | null {
  return manifest.layout[state] ?? null;
}

곱셈이 사라졌다. playback.row는 재생 정보에 남아 있지만 그리는 데는 안 쓴다. 어느 행에서 시작하는지를 사람이 볼 때만 쓴다.

같은 그림을 두 번 넣지 않는다

매니페스트를 보다가 알게 된 것이 하나 더 있다. talkingwave가 같은 행을 가리킨다.

말할 때와 손 흔들 때 같은 동작을 쓰기로 했으면 그림을 두 번 넣을 이유가 없다. 512×512 프레임 네 장이면 시트가 그만큼 커지고, 그 시트는 앱 바이너리 안에 들어간다.

이름만 따로 두고 좌표를 공유한다. 나중에 말하는 동작을 따로 그리게 되면 그때 좌표만 바꾸면 된다. 부르는 쪽 코드는 그대로다.

예제의 findAliases()가 같은 행을 가리키는 상태를 찾아낸다.

어긋난 좌표는 화면에서야 드러난다

매니페스트가 틀리면 실행해야 보인다. 캐릭터가 반쯤 잘리거나 빈 칸이 그려지는데, 원인이 매니페스트라는 걸 알아채기 어렵다. 그림 파일을 먼저 의심하게 된다.

그래서 검사를 따로 뒀다. 네 가지를 본다.

frames.forEach((rect, index) => {
  if (rect.w !== cellWidth || rect.h !== cellHeight) {
    errors.push({ kind: "size-mismatch", state, frame: index });
  }
  if (
    rect.x < 0 || rect.y < 0 ||
    rect.x + rect.w > sheetWidth ||
    rect.y + rect.h > sheetHeight
  ) {
    errors.push({ kind: "out-of-bounds", state, frame: index });
  }
});

네 가지를 본다.

검사 잡는 것
프레임 수 선언한 수와 좌표 개수가 다르다
시트 경계 시트 밖을 가리킨다
셀 크기 셀 크기와 다른 프레임이 있다
좌표 누락 재생 정보는 있는데 좌표가 없다

시트 경계 검사가 특히 쓸모 있다. 행을 추가하다가 시트 높이를 안 늘리면 마지막 상태가 통째로 밖을 가리키는데, 이 검사가 없으면 그 상태가 재생될 때까지 모른다.

안 쓰는 행도 찾는다

walk의 뒷부분을 옮기다 보면 중간에 빈 행이 생긴다. 예제에 그것도 찾는 함수를 넣었다.

빈칸은 그냥 비어 있는 게 아니라 낭비다. 512×512 한 행이면 시트에서 그만큼 자리를 차지하고, 그 시트는 앱에 통째로 들어간다.

실제 앱의 시트에도 안 쓰는 행이 하나 있었다. walk를 옮기면서 생긴 자리다.

정리

스프라이트 시트를 규칙으로 읽으려면 그 규칙이 언제 깨지는지를 먼저 정해야 한다.

  • 행·열 계산은 한 행에 들어갈 때만 맞다
  • 프레임이 늘어 행을 넘으면 계산이 조용히 틀린다. 오류가 안 난다
  • 예외를 적어 두는 대신 전부 좌표를 적는다. 한 가지 방법만 쓴다
  • 같은 그림을 쓰는 상태는 좌표를 공유한다
  • 매니페스트를 검사하지 않으면 화면에서야 드러난다
  • 안 쓰는 행은 시트를 그만큼 키운다

돌아가는 코드는 pet-reference/sprite-sheet에 있다. 매니페스트 표본에 walk가 끊어진 것과 별칭이 실제 그대로 들어 있어서, 계산 방식이 어디서 깨지는지 직접 볼 수 있다.

에서 캐릭터가 걷는 것을 보면 8프레임이 도는 것이다. 4프레임으로 튀지 않는다.

다음 편은 그 시트를 만드는 쪽이다. 프레임 수백 장을 같은 규격으로 맞추고, 중간에 깨진 파일을 어떻게 찾아내는지 다룬다.

이 블로그 더 보기

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

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

답글 남기기

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