게임 잼 이틀째입니다. 아티스트가 팀 채팅방에 폴더 하나를 올립니다. 48 × 64 달리기 프레임 12장, 대기 프레임 8장, 점프 프레임 6장, 24 × 24 UI 아이콘 20개, 32 × 32 바닥 타일 한 세트. 모두 60개의 PNG 파일입니다. 첫 빌드는 파일을 하나씩 따로 불러오므로 시작 화면이 요청 60개를 기다립니다. 카메라를 1.5배로 확대하면 바닥 타일 사이에 어두운 가는 선이 생기고, 플레이어가 걸을 때마다 깜빡입니다.

두 문제의 해법은 같습니다. 모든 이미지를 텍스처 하나, 즉 스프라이트 시트(텍스처 아틀라스라고도 합니다)에 모으고, 각 이미지의 위치를 적은 데이터 파일을 함께 두는 것입니다. 이때 아틀라스는 빈틈없이 채워야 하고, 달리기 프레임의 투명 테두리는 애니메이션이 흔들리지 않게 잘라내야 하며, 타일 가장자리는 GPU가 옆 이미지의 픽셀을 섞지 않도록 따로 손봐야 합니다.

이 글은 각 단계가 어떻게 동작하는지 설명합니다. 텍스처 하나가 60개보다 빠른 이유, MaxRects 패킹 알고리즘이 사각형을 놓는 방식, 트림 후 spriteSourceSize의 의미, Spacing과 Extrude가 이음새를 막는 원리를 차례로 다룹니다. 코드 예제에서는 결과물을 Phaser, PixiJS, 순수 CSS로 불러오고, Python 스크립트로 좌표를 검사합니다.

스프라이트 시트 생성기 열기 →

스프라이트 시트가 필요한 경우

상황필요한 것선택할 설정
Phaser나 PixiJS에서 쓸, 크기가 제각각인 캐릭터 애니메이션 프레임PNG 한 장과 이름으로 찾는 프레임 데이터Packed, Trim 켬, JSON Hash
Phaser load.spritesheet나 hframes·vframes를 쓰는 Godot Sprite2D용 프레임 애니메이션고정 위치에 놓인 같은 크기의 셀Grid, Trim 끔, Power of two 끔
확대·축소하거나 소수 좌표로 스크롤할 타일맵용 타일타일 사이에 이음새가 없을 것Packed 또는 Grid, Spacing 2, Extrude 1 또는 2
게임 엔진 없이 웹 페이지에서 쓸 UI 아이콘 세트이미지 한 장과 아이콘별 클래스Packed, Trim 끔, CSS
Starling, Sparrow 등 Sparrow XML 형식을 읽는 엔진용 에셋TextureAtlas XMLPacked, XML
로딩 스피너를 CSS steps()로 애니메이션같은 크기 프레임이 한 줄로 늘어선 시트Grid, Columns = 프레임 수, Spacing 0
여전히 WebGL 1에서 돌아가고 밉맵이 필요한 대상2의 거듭제곱 크기 시트Power of two 켬

Packed는 가장 적은 픽셀에 가장 많은 이미지를 담지만 데이터 파일이 필요합니다. Grid는 공간을 조금 낭비하는 대신 프레임 너비와 높이만 아는 로더에서도 그대로 동작합니다.

텍스처 하나가 60개보다 빠른 이유: 드로우 콜과 텍스처 바인딩

GPU는 배치(batch) 단위로 그립니다. 렌더러는 상태(셰이더, 블렌드 모드, 텍스처)가 같은 스프라이트를 모아 드로우 콜(draw call) 한 번으로 보냅니다. 다음 스프라이트가 바인딩되지 않은 텍스처를 쓰면 배치가 끝나고 새 배치가 시작됩니다. MDN의 WebGL best practices는 아틀라스를 쓰는 이유를 이렇게 설명합니다. 텍스처를 바꾸려면 드로우 콜 배치를 나눠야 하므로, 텍스처 아틀라스를 쓰면 여러 드로우 콜을 더 적고 더 큰 배치로 합칠 수 있다는 것입니다.

최신 2D 렌더러는 배치 하나에 텍스처를 여러 개 바인딩해 이 부담을 줄입니다. 예를 들어 PixiJS v8의 WebGL 렌더러는 배치당 텍스처 수를 GPU의 MAX_TEXTURE_IMAGE_UNITS에서 가져오는데, OpenGL ES 2.0이 보장하는 이 값의 최솟값은 8입니다. 따라서 이미지 60개를 따로 쓰는 장면은 그런 GPU에서 배치가 최소 8개 필요하고(유닛이 16개인 GPU라면 4개), 그리기 순서가 텍스처 사이를 오가면 더 늘어납니다. 아틀라스 하나를 쓰면 60개 스프라이트가 모두 같은 텍스처를 쓰므로 배치 하나에 들어갈 수 있습니다.

드로우 콜은 이득의 일부일 뿐입니다. 나머지는 다음과 같습니다.

  • 요청 수 감소. PNG 한 개와 JSON 한 개는 작은 파일 60개보다 빨리 로드됩니다. 모바일 네트워크에서는 차이가 더 큽니다.
  • 파일 오버헤드 감소. PNG 한 장에는 시그니처 하나와 헤더 청크 한 벌이 들어가지만, 파일 60개에는 60벌이 들어갑니다.
  • 업로드 한 번. GPU가 텍스처를 60개가 아니라 1개만 받습니다. 관리할 밉맵 체인과 샘플러 설정도 한 벌뿐입니다.

대신 엔진이 아틀라스 안에서 각 이미지의 위치를 알아야 합니다. 그 역할을 데이터 파일이 맡습니다.

패킹의 원리: MaxRects와 Best Short Side Fit

크기가 다른 사각형을 가능한 한 작은 면적에 배치하는 문제를 2차원 빈 패킹(bin packing) 문제라고 합니다. NP-난해 문제이므로 실용적인 패커는 모두 휴리스틱을 씁니다. 텍스처 아틀라스 분야의 표준 참고 자료는 Jukka Jylänki가 2010년 2월 27일에 발표한 조사 논문 A Thousand Ways to Pack the Bin – A Practical Approach to Two-Dimensional Rectangle Bin Packing입니다. 이 논문은 Shelf, Guillotine, Skyline, Maximal Rectangles 계열 알고리즘을 비교하면서 텍스처 아틀라스 생성을 실제 테스트 사례로 쓰고, 가장 성능이 좋은 알고리즘은 MAXRECTS 변형이라고 결론짓습니다.

빈 사각형 목록

MaxRects는 빈 사각형(free rectangle) 목록을 유지합니다. 각 사각형은 극대 빈 영역입니다. 즉, 어느 방향으로 늘려도 이미 놓인 스프라이트를 덮게 되는 영역입니다. 빈 사각형끼리는 서로 겹칠 수 있고, 이 점이 Guillotine 방식과의 핵심 차이입니다.

100 × 100 빈(bin)에서 시작해 60 × 40 스프라이트를 왼쪽 위에 놓아 봅시다. 하나였던 빈 사각형이 스프라이트 주변의 띠로 나뉩니다.

+------------+-------+        A(60 x 40)를 놓은 뒤의 빈 사각형:
|            |       |
|     A      |   R   |        R = x 60, y 0,  w 40,  h 100  (A의 오른쪽)
|  60 x 40   |       |        B = x 0,  y 40, w 100, h 60   (A의 아래쪽)
+------------+  - - -|
|        B           |        R과 B는 오른쪽 아래 모서리에서 겹칩니다.
|                    |        둘 다 극대 사각형입니다.
+--------------------+

이후 스프라이트를 놓을 때마다, 그 스프라이트가 닿는 빈 사각형은 최대 네 개의 띠(왼쪽, 오른쪽, 위, 아래)로 잘립니다. 다른 빈 사각형 안에 완전히 포함되는 띠는 제거됩니다. 그래서 목록은 항상 스프라이트를 놓을 수 있는 모든 빈 자리를 나타냅니다.

자리 고르기: Best Short Side Fit

패커는 스프라이트마다 충분히 큰 빈 사각형을 모두 시험해 점수를 매깁니다. 논문은 여러 규칙을 소개합니다. Best Short Side Fit(MAXRECTS-BSSF)는 min(freeW - w, freeH - h)가 가장 작은 빈 사각형, 즉 남는 변 중 짧은 쪽이 가장 작은 자리를 고릅니다. 동점이면 Jylänki의 참조 구현처럼 남는 변 중 긴 쪽이 더 작은 자리를 고릅니다.

예제를 이어 30 × 30 스프라이트를 놓아 봅시다. R에서는 가로로 10, 세로로 70이 남으므로 짧은 쪽은 10입니다. B에서는 70과 30이 남으므로 짧은 쪽은 30입니다. R이 이기고, 스프라이트는 A 바로 옆인 (60, 0)에 놓입니다. 이 규칙은 한 변이 거의 딱 맞는 자리를 선호하므로, 자잘한 조각 대신 쓸모 있는 긴 띠가 남습니다.

순서가 중요하다

MaxRects는 온라인 알고리즘이라 주어진 순서대로 스프라이트를 하나씩 놓습니다. 큰 스프라이트를 마지막에 놓으면 들어갈 자리가 없으므로 패커는 먼저 정렬합니다. 스프라이트 시트 생성기는 긴 변이 큰 순서로, 그다음 면적 순으로 정렬합니다. 긴 변이 같은 두 스프라이트라면 면적이 큰 쪽이 짧은 변도 크므로, 이 순서는 논문에서 -DESCLS라고 부르는 정렬과 같습니다.

시트 크기 정하기

패커는 시트 너비도 정해야 합니다. 스프라이트 시트 생성기는 MaxRects를 여러 번 돌립니다.

  1. 가장 넓은 스프라이트부터 Max width(512, 1024, 2048, 4096, 8192px 중 선택, 기본값 2048)까지의 여러 너비와, 전체 스프라이트 면적의 제곱근 근처 너비를 시험합니다. Power of two가 켜져 있으면 2의 거듭제곱만 시험합니다.
  2. 너비마다 모든 스프라이트를 담을 수 있는 가장 작은 높이에서 시작해, 전부 들어갈 때까지 높이를 늘립니다.
  3. 캔버스 제한 안에 들어오는 결과만 남기고, 최소 면적의 10% 이내인 결과는 모두 동등하게 보아 그중 정사각형에 가장 가까운 것을 고릅니다.

10% 규칙을 두는 이유는 아주 좁은 시트가 몇 퍼센트 더 빽빽하게 채워지는 대신 92 × 15357px 같은 크기가 나오기 쉬워서입니다. 이런 크기는 많은 GPU의 텍스처 크기 제한을 넘습니다. 정사각형에 가까운 시트가 더 안전합니다.

프레임은 회전하지 않습니다. 90° 회전하면 공간을 아낄 수 있지만, 엔진이 텍스처 좌표를 다시 돌려놓아야 하고 모든 로더가 그렇게 하지는 않습니다. TexturePacker 문서도 회전 옵션에 대해 모든 게임·웹 프레임워크가 지원하지는 않을 수 있다고 적고 있습니다. 모든 프레임을 똑바로 두면 이 형식을 읽는 어떤 로더에서도 결과물이 동작합니다.

트림: frame, spriteSourceSize, sourceSize

애니메이션 프레임은 캐릭터가 제자리에 머물도록 보통 같은 캔버스 크기를 공유합니다. 64 × 64 달리기 프레임 안의 캐릭터는 30 × 44밖에 안 되고 나머지는 투명 픽셀일 수 있습니다. 64 × 64 전체를 패킹하면 공간의 약 3분의 2를 낭비합니다.

트림은 각 이미지를 보이는 픽셀의 경계 상자(bounding box)만큼 잘라냅니다. 이 도구는 알파가 0보다 큰 픽셀은 모두 남기고, 네 변에서 완전히 투명한 행과 열을 제거합니다. 그다음 데이터 파일이 잘라낸 상자의 원래 위치를 기억해야 합니다. 그렇지 않으면 프레임마다 상자 크기가 달라져 애니메이션이 튑니다.

다음은 Margin을 2로 설정해 JSON Hash로 내보낸 프레임 하나입니다.

"walk_01.png": {
  "frame": { "x": 2, "y": 2, "w": 30, "h": 44 },
  "rotated": false,
  "trimmed": true,
  "spriteSourceSize": { "x": 17, "y": 10, "w": 30, "h": 44 },
  "sourceSize": { "w": 64, "h": 64 }
}
  • frame은 시트에서 잘라낼 사각형입니다. (2, 2) 위치의 30 × 44 픽셀입니다.
  • sourceSize는 원본 이미지 크기인 64 × 64입니다.
  • spriteSourceSize는 트림된 픽셀이 원본 안에서 있던 위치입니다. 왼쪽에서 17px, 위에서 10px 떨어져 있습니다.

엔진은 이 프레임을 그릴 때 64 × 64 상자를 쓰고, 30 × 44 픽셀을 (17, 10) 오프셋에 놓습니다. 스프라이트의 크기와 앵커는 트림하지 않은 이미지와 같고, 작아진 것은 텍스처뿐입니다. Phaser의 JSONHash 파서는 trimmed가 true이면 이 값을 Frame.setTrim에 넘깁니다. PixiJS v8의 Spritesheet는 spriteSourceSize로 트림 사각형을 만들고 sourceSize를 원본 크기로 씁니다.

Sparrow·Starling XML 형식은 같은 정보를 다르게 저장합니다. 오프셋이 음수인데, 트림된 픽셀을 기준으로 원래 프레임이 어디서 시작하는지를 나타내기 때문입니다.

<SubTexture name="walk_01.png" x="2" y="2" width="30" height="44"
  frameX="-17" frameY="-10" frameWidth="64" frameHeight="64"/>

트림하지 않은 프레임에는 frame* 속성 네 개가 없습니다. Starling의 TextureAtlas 소스에도 같은 구조가 문서화되어 있고, Phaser의 AtlasXML 파서는 frameX와 frameY의 절댓값을 씁니다.

완전히 투명한 이미지에는 남길 픽셀이 없습니다. 도구는 이런 이미지를 1 × 1 프레임으로 저장하고 sourceSize는 유지합니다. 그래서 애니메이션 중간의 빈 프레임도 타이밍상 제자리를 지킵니다.

텍스처 번짐: Spacing, Extrude, 밉맵

이웃 픽셀이 섞여 드는 이유

GPU가 텍셀 하나를 화면 픽셀 하나에 정확히 대응시키는 경우는 드뭅니다. 스프라이트를 확대·축소하거나 회전하거나 소수 좌표에 그리면, 샘플러는 텍셀 중심 사이의 위치에서 텍스처를 읽습니다. 바이리니어 필터링(LINEAR)에서는 샘플 하나가 가장 가까운 텍셀 네 개의 가중 평균입니다. 프레임 가장자리에서는 그 네 개 중 두 개가 아틀라스 속 옆 스프라이트의 텍셀일 수 있습니다.

그 결과 가장자리를 따라 엉뚱한 색의 가는 선이 생깁니다. 이웃이 투명한 검정이라 선은 대개 어둡고, 카메라가 움직이면 함께 움직입니다. 도입부의 바닥 타일이 바로 이 경우입니다. 1.5배로 확대하면 각 타일 가장자리의 텍스처 좌표가 텍셀 사이에 떨어지고, 필터가 옆 타일의 색을 섞습니다.

번짐을 막는 세 가지 설정

  • Spacing은 스프라이트 사이에 투명 픽셀을 둡니다. 필터는 가장자리를 다른 스프라이트 대신 투명색과 섞습니다. 스프라이트 시트 생성기의 기본값은 2px입니다. TexturePacker 문서도 shape padding 값으로 같은 숫자를 권합니다. OpenGL로 렌더링할 때 이웃 스프라이트의 픽셀이 끌려 들어오지 않게 하려면 2 이상을 쓰라는 것입니다.
  • Extrude는 각 스프라이트의 가장 바깥 행과 열을 모서리까지 포함해 1~8px 바깥으로 복제합니다. 데이터 파일의 프레임 크기는 그대로이므로 엔진이 복제된 픽셀을 직접 보여 주는 일은 없습니다. 대신 필터가 각 가장자리를 같은 색과 섞게 됩니다. 불투명한 타일에서 특히 중요합니다. 두 타일이 맞닿을 때 투명색과 섞인 가장자리는 희미한 이음새로 보이지만, 가장자리 복제본과 섞인 가장자리는 보이지 않습니다.
  • Margin은 시트 전체 둘레에 빈 테두리를 둡니다. 그래서 시트 가장자리의 스프라이트도 가운데 스프라이트와 똑같이 보호받습니다.

각 스프라이트는 자기 크기에 2 × extrude와 Spacing을 더한 공간을 차지합니다. 따라서 출력에서 이웃한 두 프레임 사이의 간격은 spacing + 2 × extrude입니다. 아래 Python 예제가 이 값을 검사합니다.

밉맵은 더 많은 여유가 필요하다

밉맵을 쓰면 문제가 커집니다. 밉 레벨이 하나 올라갈 때마다 해상도가 절반이 되므로, 레벨 1에서는 텍셀 하나가 시트의 2 × 2 영역을, 레벨 2에서는 4 × 4 영역을, 레벨 k에서는 한 변 2^k 픽셀 영역을 덮습니다. 2px 간격은 대략 레벨 1까지만 깨끗하게 지켜 줍니다. 레벨 3이 되면 샘플러를 아무리 신경 써서 설정해도 텍셀마다 이웃 픽셀을 포함한 8 × 8 영역의 평균이 됩니다.

스프라이트를 텍스처 크기보다 훨씬 작게 그린다면 Spacing과 Extrude를 늘리거나, 엔진이 쓰는 가장 낮은 밉 레벨을 제한하거나, 그 스프라이트들을 별도 텍스처로 분리하세요. 최근접 이웃 필터로 정수 픽셀 위치에 그리는 픽셀 아트는 텍셀이 섞이지 않으므로 Spacing, Extrude, Margin을 모두 0으로 두어도 됩니다. Phaser에서는 게임 설정의 pixelArt: true가 안티앨리어싱을 끄고 픽셀 반올림을 켭니다. PixiJS v8에서는 시트를 불러올 때 텍스처 옵션에 scaleMode: 'nearest'를 넘기세요.

2의 거듭제곱 규칙은 어디서 왔나

오래된 가이드는 스프라이트 시트의 각 변이 256, 512, 1024, 2048픽셀이어야 한다고 말합니다. 이 규칙은 WebGL 1과 OpenGL ES 2.0에서 왔습니다. MDN 한국어 문서 WebGL에서 텍스쳐 사용하기의 ‘크기가 2의 거듭제곱이 아닌 텍스쳐’ 절에 조건이 정리되어 있습니다. 2의 거듭제곱이 아닌 텍스처는 필터를 gl.LINEAR나 gl.NEAREST로만 설정할 수 있고 둘 다 밉맵을 만들 수 없으며, 래핑 모드도 CLAMP_TO_EDGE로 지정해야 합니다. 이 설정을 하지 않으면 WebGL은 NPOT 텍스처 대신 검은색(rgba(0, 0, 0, 1))을 돌려줍니다.

WebGL 1에서는 여기서 두 가지 결과가 나옵니다.

  • 2의 거듭제곱이 아닌(NPOT) 텍스처는 밉맵을 가질 수 없습니다. 화면을 크게 축소하면서도 부드럽고 깜빡임 없는 축소 표시가 필요한 게임이라면 2의 거듭제곱 시트가 필요합니다.
  • NPOT 텍스처는 REPEAT나 MIRRORED_REPEAT 래핑을 쓸 수 없습니다. 다만 시트 전체를 반복하면 그 안의 스프라이트가 모두 반복되므로 아틀라스를 반복할 일은 원래 드뭅니다.

WebGL 2에서는 밉맵 제한이 사라졌습니다. WebGL2 Fundamentals의 설명대로, WebGL1에서는 2의 거듭제곱이 아닌 텍스처에 밉맵을 쓸 수 없었지만 WebGL2에서는 이 제한이 없어졌습니다. WebGL 2를 대상으로 하는 엔진 대부분은 크기에 제약이 없습니다. 대상 환경이 여전히 WebGL 1에서 밉맵을 쓰거나, 엔진이나 압축 단계가 요구할 때 Power of two를 켜세요. 예를 들어 TexturePacker 문서는 Unity나 Unreal 같은 엔진의 외부 압축이 2의 거듭제곱 크기나 4의 배수를 요구할 수 있다고 적고 있습니다.

GPU가 받는 최대 텍스처 크기는 별개의 제한입니다. OpenGL ES 2.0이 보장하는 MAX_TEXTURE_SIZE는 64에 불과하지만 실제 하드웨어는 이를 훨씬 넘습니다. WebGL2 Fundamentals의 크로스 플랫폼 이슈 문서에 따르면 2020년 기준으로 기기의 약 99%가 4096을 지원했고, 그보다 큰 크기를 지원한 기기는 약 50%뿐이었습니다. 스마트폰에서도 돌아가야 하는 게임이라면 2048px나 4096px 시트가 안전합니다. 그보다 크게 만들 계획이라면 대상 기기에서 gl.getParameter(gl.MAX_TEXTURE_SIZE) 값을 확인하세요.

스프라이트 시트 생성기의 동작 방식

스프라이트 시트 생성기는 브라우저 탭 안에서 아틀라스를 만듭니다. 단계는 다음과 같습니다.

  1. 이미지 추가. 파일을 끌어다 놓거나, 클릭해서 고르거나, 복사한 이미지 파일을 Ctrl/Cmd+V로 붙여넣습니다. PNG, JPG, WebP, GIF, SVG, BMP, AVIF를 받으며 언제든 파일을 더 추가할 수 있습니다. 래스터 파일은 createImageBitmap으로 디코딩합니다. SVG 파일은 width, height, viewBox 속성이 가리키는 크기로 그립니다.
  2. 프레임 이름 정하기. 각 프레임의 이름은 확장자를 포함한 파일 이름(walk_01.png)이며, TexturePacker 관례와 같습니다. 이름이 같은 파일이 두 개면 두 번째 파일이 walk_01 (2).png가 됩니다. Sort를 Name으로 두면 자연 정렬을 써서 walk_2가 walk_10보다 앞에 옵니다. Added는 파일을 추가한 순서를 유지합니다.
  3. 트림. 도구는 이미지를 추가할 때 각 이미지의 보이는 경계 상자를 한 번 계산합니다. Packed 모드에서는 Trim transparent edges 체크박스(기본값 켬)가 패킹에 이 상자를 쓸지 정합니다. Grid 모드는 항상 이미지 전체를 씁니다.
  4. 배치. Packed는 앞에서 설명한 MaxRects를 실행합니다. Grid는 모든 셀을 가장 큰 이미지와 같은 크기로 만들고, 이미지를 셀 가운데에 놓은 뒤 셀 전체를 프레임으로 기록합니다. Columns 기본값은 이미지 수의 제곱근을 올림한 값입니다. Spacing(064px, 기본값 2), Margin(064, 기본값 0), Extrude(0~8, 기본값 0)는 두 레이아웃에 모두 적용됩니다.
  5. 그리기와 내보내기. 이미지를 스무딩 없이 1:1로 캔버스에 그리고, Extrude 띠를 복제한 뒤, 캔버스를 toBlob('image/png')로 인코딩합니다. 데이터 텍스트는 같은 프레임 목록으로 선택한 형식에 맞춰 만듭니다.

설정을 바꾸면 약 150ms 뒤에 시트가 자동으로 다시 만들어집니다. 정보 줄에는 시트 크기, 스프라이트 수, 채움률(스프라이트 픽셀 면적을 시트 면적으로 나눈 값)이 표시됩니다. Show bounds는 프레임 테두리를 미리보기에만 그리고 PNG에는 절대 넣지 않습니다.

제한

  • 이미지는 최대 1,000장, 한 장당 한 변 8,192px까지입니다. 이미지가 아니거나 디코딩에 실패한 파일은 건너뛰고 파일 이름을 표시합니다.
  • 시트는 한 변 16,384px 이하, 전체 16,777,216픽셀 이하입니다. canvas-size 테스트 결과에서 Mobile Safari 9 이상의 최대 캔버스 면적은 4,096 × 4,096(16,777,216픽셀)입니다. 그보다 큰 캔버스는 여기서 동작하지 않으므로, 도구는 그런 배치를 거부하고 Spacing을 줄이거나, Max width나 레이아웃을 바꾸거나, 이미지를 빼라고 안내합니다.
  • 이미지 하나가 Max width보다 넓으면 시트가 그 이미지에 맞춰 넓어지고, 상태 줄에 해당 이미지 이름이 표시됩니다.

여기서 말하는 ‘업로드 없음’의 의미

파일은 File API를 통해 디스크에서 페이지로 들어옵니다. 디코딩, 트림, 패킹, PNG 인코딩은 모두 탭 안에서 실행되고, 도구는 이미지나 출력물을 담은 네트워크 요청을 보내지 않습니다. 저장하는 것은 옵션 설정(레이아웃, 열 수, 최대 너비, 간격, 바깥 여백, 익스트루드, 트림, 2의 거듭제곱, 정렬 순서, 데이터 형식, 시트 이름, 테두리 표시)뿐이며, 로컬 스토리지에 저장합니다. 이미지, 미리보기, 출력 텍스트는 저장하지 않습니다. 사이트의 다른 도구와 마찬가지로 도구 이름과 동작(png, data, copy)을 담은 사용 이벤트를 Google Analytics에 보냅니다. 파일 이름과 내용은 여기에 포함되지 않습니다.

자주 겪는 문제와 예외 상황

애니메이션 중에 프레임이 튄다

로더가 트림 데이터를 무시하고 있습니다. spriteSourceSize 오프셋을 더하지 않고 frame을 스프라이트 위치에 그대로 그리는 자체 코드는 트림된 프레임을 저마다 다른 양만큼 어긋나게 합니다. 엔진의 아틀라스 로더를 쓰거나, 직접 작성한 코드에서 오프셋을 더하거나, Trim transparent edges를 끄세요.

Phaser 그리드 로더가 프레임을 더 찾는다

Phaser의 load.spritesheet는 데이터 파일을 읽지 않습니다. 이미지 크기로 프레임 수를 계산합니다. 열 수는 floor((width - margin + spacing) / (frameWidth + spacing))이고, 행 수도 같은 방식으로 구해 곱합니다. 그리지 않은 프레임이 생기는 원인은 두 가지입니다.

  • Power of two는 시트를 셀 하나 이상 키울 수 있습니다. 그러면 빈 열이나 행이 생기고 프레임 번호가 밀립니다. 이 로더를 쓸 때는 끄세요.
  • 이미지 수가 Columns의 배수가 아니면 마지막 행에 빈 셀이 생깁니다. 이미지 10장을 4열로 배치하면 Phaser는 프레임 12개를 만듭니다. endFrame: 9를 넘기거나(값은 해당 프레임을 포함합니다), 원하는 프레임을 직접 나열하세요.

Extrude를 E로 설정했다면 로더에 margin + E와 spacing + 2 × E를 넘깁니다.

트림이 되지 않는다

도구는 알파가 0보다 큰 픽셀을 모두 남깁니다. 부드러운 브러시나 그림자 효과가 모서리에 남긴 알파 1짜리 픽셀 하나만 있어도 캔버스 전체가 그대로 남습니다. JPG에는 알파 채널이 없고 BMP 파일도 대부분 없으므로 잘라낼 것이 없습니다. 원본 이미지를 정리하거나, 실제 알파 채널이 있는 PNG로 내보내세요.

CSS 아이콘의 여백이 사라진다

CSS 형식은 프레임 크기와 위치만 기록합니다. Trim을 켜면 각 아이콘 클래스가 보이는 픽셀만큼의 크기를 갖게 되므로, 24 × 24 아이콘이 18 × 20이 되어 버튼 줄에서 가운데를 벗어날 수 있습니다. CSS로 내보내기 전에 Trim을 끄세요. CSS 출력에는 background-size도 없습니다. HiDPI 화면용이라면 @2x 이미지를 패킹하고 background-size를 시트 크기의 절반으로 지정한 뒤, 모든 width, height, background-position 값을 절반으로 줄이세요.

한글 파일 이름과 시트 이름

아티스트가 홈.png, 검색.png처럼 한글 파일 이름으로 에셋을 넘겨주는 경우가 있습니다. JSON과 XML은 파일 이름을 그대로 프레임 이름으로 쓰므로 홈.png도 키로 문제없이 쓸 수 있습니다. CSS 클래스 이름은 확장자를 뗀 파일 이름을 소문자로 바꾸고, 문자(한글 포함), 숫자, _, - 이외의 문자를 하이픈으로 바꿔 만듭니다. 그래서 홈.png는 .icons-홈, btn_홈.png는 .icons-btn_홈이 됩니다. 한글은 CSS 식별자에 쓸 수 있는 문자라 그대로 동작합니다. @#.png처럼 기호만 있는 이름은 .icons-frame이 됩니다.

Sheet name은 규칙이 다릅니다. 이 이름은 PNG 파일 이름과 JSON의 meta.image에 들어가므로 영문, 숫자, ., _, -만 남기고, 영문이나 숫자가 하나도 남지 않으면 spritesheet를 씁니다. 아이콘을 입력하면 spritesheet.png와 .spritesheet가 됩니다. 시트 이름은 icons처럼 영문으로 지으세요.

PixiJS가 PNG를 찾지 못한다

Assets.load는 JSON에서 meta.image를 읽어 같은 폴더에서 그 파일을 불러옵니다. 내보낸 뒤 PNG 이름을 바꾸면 로드에 실패합니다. Phaser는 PNG 주소를 별도 인자로 받고 meta.image를 읽지 않으므로 같은 이름 변경이 문제되지 않습니다. 내보내기 전에 Sheet name을 정하고, 두 파일을 함께 두세요.

Grid 모드에서 Extrude가 효과가 없다

Grid 모드에서는 셀 전체가 프레임이고, Extrude는 셀 가장자리를 복제합니다. 이미지가 셀보다 작으면 그 가장자리는 투명하므로 복제해도 달라지는 것이 없습니다. Grid 모드의 Extrude는 타일처럼 모든 이미지가 셀을 꽉 채울 때 효과가 있습니다. 크기가 섞여 있다면 Packed를 쓰세요.

GIF의 첫 프레임만 나온다

도구는 브라우저의 이미지 디코더로 GIF를 디코딩하며, 이 디코더는 첫 프레임만 돌려줍니다. 움직이는 GIF의 모든 프레임을 패킹하려면 먼저 GIF 프레임 추출기로 나눈 뒤, 그 프레임들을 여기에 넣으세요.

중복 프레임이 공간을 두 번 차지한다

두 이미지가 똑같아도 모든 파일을 패킹합니다. 목록에서 중복을 지우거나, 엔진의 애니메이션 정의에서 프레임 이름 하나를 재사용하세요.

코드 예제

예제는 run_01.png부터 run_12.png까지의 프레임을 담은 hero 시트를 hero.png와 hero.json으로 내보냈다고 가정합니다.

Phaser: 아틀라스를 불러와 애니메이션 만들기

this.load.atlas는 텍스처 URL과 JSON URL을 받습니다. JSON Hash와 JSON Array를 모두 받는데, Phaser의 텍스처 매니저가 frames가 배열인지 확인해 맞는 파서를 고르기 때문입니다. 아래 코드는 Phaser 3.90과 4.x 소스에서 똑같이 쓸 수 있는 로더·애니메이션 API만 씁니다.

class Play extends Phaser.Scene {
  preload() {
    this.load.atlas('hero', 'assets/hero.png', 'assets/hero.json');
    // XML 형식: this.load.atlasXML('hero', 'assets/hero.png', 'assets/hero.xml');
  }

  create() {
    this.anims.create({
      key: 'run',
      frames: this.anims.generateFrameNames('hero', {
        prefix: 'run_', start: 1, end: 12, zeroPad: 2, suffix: '.png'
      }),
      frameRate: 14,
      repeat: -1
    });

    this.add.sprite(160, 120, 'hero', 'run_01.png').play('run');
  }
}

new Phaser.Game({
  type: Phaser.AUTO,
  width: 320,
  height: 240,
  pixelArt: true, // 안티앨리어싱 끔, roundPixels 켬
  scene: Play
});

프레임 이름에 .png가 포함되므로 generateFrameNames에 suffix: '.png'가 필요합니다. 데이터 파일 없이 Grid 시트를 불러올 때는 다음과 같이 씁니다.

// Grid로 내보낸 48 x 64 프레임 10개, Spacing 2, Margin 0, Extrude 1
this.load.spritesheet('coin', 'assets/coin.png', {
  frameWidth: 48,
  frameHeight: 64,
  margin: 0 + 1,      // margin + extrude
  spacing: 2 + 2 * 1, // spacing + 2 x extrude
  endFrame: 9         // 마지막 행의 빈 셀 건너뛰기
});

PixiJS v8: Assets.load와 AnimatedSprite

PixiJS는 frames를 프레임 이름을 키로 하는 객체로 정의하므로 JSON Hash로 내보내세요. Assets.load는 Spritesheet를 돌려주며, 그 textures 객체에 프레임마다 텍스처가 하나씩 들어 있습니다.

import { Application, Assets, AnimatedSprite } from 'pixi.js';

const app = new Application();
await app.init({ width: 320, height: 240 });
document.body.appendChild(app.canvas);

const sheet = await Assets.load({
  src: 'assets/hero.json',
  data: { textureOptions: { scaleMode: 'nearest' } } // 선명한 픽셀 아트
});

const runFrames = Object.keys(sheet.textures)
  .filter((name) => name.startsWith('run_'))
  .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
  .map((name) => sheet.textures[name]);

const runner = new AnimatedSprite({
  textures: runFrames,
  animationSpeed: 0.25, // 60fps 틱당 진행하는 프레임 수: 초당 약 15프레임
  autoPlay: true
});
runner.position.set(160, 120);
app.stage.addChild(runner);

numeric: true 옵션은 run_2를 run_10보다 앞에 정렬합니다. 도구의 Name 정렬과 같은 자연 정렬입니다. 예전 생성자 형태인 new AnimatedSprite(textures)도 v8에서 여전히 동작합니다.

CSS: 아이콘과 steps() 애니메이션

Sheet name을 icons로 두고 CSS 형식으로 내보내면 다음과 같은 결과가 나옵니다(위치는 배치에 따라 다릅니다).

.icons {
  display: inline-block;
  background-image: url("icons.png");
  background-repeat: no-repeat;
}

.icons-home {
  width: 24px;
  height: 24px;
  background-position: 0 0;
}

.icons-search {
  width: 24px;
  height: 24px;
  background-position: -26px 0;
}
<button><span class="icons icons-search"></span> 검색</button>

스피너라면 64 × 64 프레임 8개를 Columns 8, Spacing 0의 Grid로 내보냅니다. 그러면 512 × 64 시트가 됩니다. steps(8)은 한 번에 프레임 너비 하나씩 건너뜁니다.

.spinner {
  width: 64px;
  height: 64px;
  background: url("spinner.png") no-repeat 0 0;
  animation: spin 0.8s steps(8) infinite;
}

@keyframes spin {
  to { background-position: -512px 0; }
}

Python: 배포 전에 좌표 검사하기

이 스크립트는 JSON Hash 또는 JSON Array로 내보낸 파일을 읽어, 모든 프레임이 시트 안에 있는지, 트림 데이터가 일관적인지, 지정한 간격보다 가까운 프레임 쌍이 없는지 검사합니다. --gap에는 spacing + 2 × extrude를 넘깁니다. 또 meta.image에 적힌 PNG가 JSON 파일 옆에 있는지(PixiJS가 찾는 위치와 같습니다), Pillow가 설치되어 있다면 그 크기가 meta.size와 일치하는지도 확인합니다.

#!/usr/bin/env python3
"""check_atlas.py: TexturePacker 스타일 JSON 아틀라스를 점검합니다."""
import argparse
import json
import sys
from pathlib import Path


def frames_of(atlas):
    frames = atlas["frames"]
    if isinstance(frames, dict):          # JSON Hash
        return list(frames.items())
    return [(f["filename"], f) for f in frames]  # JSON Array


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("atlas")
    ap.add_argument("--gap", type=int, default=0, help="spacing + 2 * extrude")
    args = ap.parse_args()

    path = Path(args.atlas)
    atlas = json.loads(path.read_text(encoding="utf-8"))
    sheet_w, sheet_h = atlas["meta"]["size"]["w"], atlas["meta"]["size"]["h"]
    errors, rects = [], []

    for name, f in frames_of(atlas):
        r, ss, src = f["frame"], f["spriteSourceSize"], f["sourceSize"]
        x, y, w, h = r["x"], r["y"], r["w"], r["h"]
        if x < 0 or y < 0 or x + w > sheet_w or y + h > sheet_h:
            errors.append(f"{name}: frame {w}x{h} at {x},{y} is outside {sheet_w}x{sheet_h}")
        if f.get("rotated"):
            errors.append(f"{name}: rotated frames are not expected")
        if (ss["w"], ss["h"]) != (w, h):
            errors.append(f"{name}: spriteSourceSize size differs from frame size")
        if ss["x"] < 0 or ss["y"] < 0 or ss["x"] + w > src["w"] or ss["y"] + h > src["h"]:
            errors.append(f"{name}: trimmed box does not fit inside sourceSize")
        rects.append((name, x, y, w, h))

    g = args.gap
    for i, (na, ax, ay, aw, ah) in enumerate(rects):
        for nb, bx, by, bw, bh in rects[i + 1:]:
            apart_x = ax + aw + g <= bx or bx + bw + g <= ax
            apart_y = ay + ah + g <= by or by + bh + g <= ay
            if not (apart_x or apart_y):
                errors.append(f"{na} and {nb} overlap or are closer than {g} px")

    png = path.parent / atlas["meta"]["image"]
    if not png.exists():
        errors.append(f"meta.image {png.name} is not next to the JSON file")
    else:
        try:
            from PIL import Image
            with Image.open(png) as im:
                if im.size != (sheet_w, sheet_h):
                    errors.append(f"PNG is {im.size[0]}x{im.size[1]}, meta.size says {sheet_w}x{sheet_h}")
        except ImportError:
            pass  # Pillow 미설치: 크기 검사 생략

    for e in errors:
        print("ERROR", e)
    print(f"{len(rects)} frames, {len(errors)} problems")
    sys.exit(1 if errors else 0)


if __name__ == "__main__":
    main()

기본 설정이라면 python check_atlas.py assets/hero.json --gap 2로 실행합니다. 쌍 검사는 모든 프레임을 다른 모든 프레임과 비교합니다. 프레임이 1,000개면 비교가 약 500,000번이고, 순수 Python으로도 1분보다 훨씬 짧게 끝납니다. 아틀라스를 게임에 복사하는 빌드 옆에 CI 단계로 넣기에 충분합니다.

TexturePacker, free-tex-packer와의 비교

세 도구는 아틀라스를 같은 개념으로 다루며, 이들이 출력하는 JSON Hash, JSON Array, Sparrow XML 형식은 같은 로더로 읽힙니다. 차이는 기능 범위와 실행 위치에 있습니다.

항목ZeroTool 스프라이트 시트 생성기TexturePackerfree-tex-packer
실행 위치브라우저 탭, 파일은 로컬에 머묾Windows·macOS·Linux 데스크톱 앱과 커맨드 라인웹 앱, Windows·macOS·Linux 데스크톱 앱, CLI와 gulp·grunt·webpack 플러그인
라이선스무료 웹 도구상용, 무료 체험판 있음오픈 소스, MIT
패킹MaxRects(Best Short Side Fit), GridGrid, Basic, MaxRects, Polygon여러 배치 규칙을 갖춘 MaxRects(Best Short Side Fit 포함)
회전없음선택선택
트림있음Trim, CropTrim, Crop
간격과 가장자리 복제Spacing, Margin, ExtrudeShape padding, Border padding, ExtrudePadding, Extrude
한 세트에서 여러 시트 생성없음MultipackMultipacking
동일 스프라이트 한 번만 저장없음Alias 감지Detect identical 옵션
이미지 출력PNGPNG, WebP, JPG, PVR·KTX·ASTC 같은 GPU 형식PNG 또는 JPG
데이터 형식JSON Hash, JSON Array, Sparrow/Starling XML, CSS48개 이상 엔진용 프리셋, 범용 JSON·XML, 사용자 정의 형식JSON Hash·Array, XML, CSS, Phaser·PixiJS·Godot·Spine·cocos2d·Starling·Unity·Unreal 등 프리셋, 사용자 정의 템플릿

셋 중 가장 완성도가 높은 것은 TexturePacker입니다. 폴리곤 패킹, Alias 감지, 기기별 스케일링, 9-slice·피벗 편집기, GPU 텍스처 압축은 대형 게임의 프로덕션 파이프라인에서 쓰는 기능이고, 커맨드 라인은 빌드 서버에 잘 맞습니다. 스프라이트 세트 하나에서 아틀라스 페이지를 여러 장 만들거나, 회전 프레임, WebP나 하드웨어 압축 출력, Unity 같은 엔진 전용 형식이 필요하면 TexturePacker를 쓰세요.

free-tex-packer는 회전, 멀티패킹, 다양한 엔진 프리셋을 지원하고, mustache 템플릿으로 데이터 형식을 직접 정의할 수 있습니다. 이런 옵션을 오픈 소스 도구나 gulp·grunt·webpack 빌드 안에서 쓰고 싶다면 free-tex-packer가 맞습니다.

스프라이트 시트 생성기는 그 사이의 흔한 경우를 맡습니다. 지금 당장 PNG 묶음을 Phaser, PixiJS, Starling이 읽는 형식으로 패킹하고, 트림·Spacing·Extrude를 처리하며, 아무것도 설치하지 않고 파일도 기기 밖으로 내보내지 않는 경우입니다. 설계상 똑바로 선 프레임으로 PNG 한 페이지와 위의 네 가지 데이터 형식만 출력합니다. 회전, 여러 페이지 아틀라스, WebP 출력, 애니메이션 미리보기, Unity·Godot 형식은 TexturePacker와 free-tex-packer의 영역입니다.

관련 도구와 참고 자료

스프라이트 시트와 함께 쓰기 좋은 ZeroTool 도구:

이 글에서 참고한 1차 자료: