Pyxel 3.0에 소프트웨어 3D 기능인 pyxel.cube가 추가되었습니다. AI 에이전트를 위한 공식 MCP 서버 pyxel-mcp 역시 1.4.0(2026-11-12) 버전에서 Pyxel 3 및 pyxel.cube를 지원하게 되었습니다 (에이전트용 스킬인 pyxel-skill도 있습니다).
최근 몇 주 동안 AI와 함께 Pyxel 3의 3D 게임이나 도구를 10개 이상 만들었으며, 로컬 LLM을 이용한 3D 소품 생성과 브라우저 내 AI 테스트를 진행했습니다. 그 과정에서 **'2D로 맡긴다고 생각하고 AI에게 의존할 때 에러는 발생하지 않지만 화면이나 속도가 망가지는 지점'**과 **'인간과 AI의 역할 분담에 대한 현실적인 해답'**이 명확하게 보였습니다.
본 기사에서는 AI에게 Pyxel 3의 3D를 작성하게 할 때 빠지기 쉬운 함정, 가장 먼저 제공해야 할 규칙표, 자동 검증을 돌리는 방법, 그리고 3D 소재를 만들게 한 세 가지 실험 결과를 실측값과 함께 정리합니다.
검증 환경
- Pyxel 3.0.1 / pyxel-mcp 1.4.0 (2026-11-12 버전)
- 로컬 실행: MacBook Pro M5 Max (macOS / Ollama 0.40.8)
- 헤드리스 검증: Linux + Xvfb (Pyxel 3.0.1)
- 브라우저 실행: Pyxel Code Maker / Code & Magic (Chrome, Pyodide / WASM)
본 기사에 등장하는 자체 제작 도구들(예: look.py, check_spec.py, run_html.cjs, fix_glb.py, pixel_shape.py 등)은 저를 위해 만든 것이라 공개하지 않습니다. 생각의 흐름과 결과 소개 목적으로 읽어주시기 바랍니다.
pyxel.cube 자체의 사양이나 GLB・VRM 제작 방법은 이전 기사에 정리했습니다. 중복되는 부분은 링크만 걸고, 본 기사에서는 'AI와 함께 작업할 때 어떻게 해야 하는지'에 초점을 맞춥니다.
AI에게 Pyxel 게임을 작성하게 하면 2D의 경우 높은 확률로 그대로 작동합니다. 하지만 Pyxel 3의 3D(pyxel.cube)와 새로운 MML은 공개된 지 얼마 되지 않아 AI 학습 데이터가 적고, 게다가 'Python 예외는 발생하지 않지만 시각적 요소나 음향만 망가지는' 부분이 여러 곳에 있습니다.
실제로 AI가 반복해서 작성한 '에러가 나지 않는 버그'와 그 실측값은 다음과 같습니다.
| AI가 자주 하는 작성 방식 | 발생하는 현상 (모두 예외 없음) | 실측값/근거 | 올바른 작성 방식 |
|---|
pyxel.colors = PAL | Colors 타입이 단순한 list로 대체되어 엔진의 팔레트가 바뀌지 않음 | GLB 색상이 오래된 팔레트에 흡착됨 (원했던 16번이 8번에 변질) | (슬라이스 할당)을 pyxel.colors[:] = PAL 로 변경하여 Mesh.from_glb 전에 실행<br>MML에서 v12 ~ v15 |
| 음량이 극단적으로 작아짐 (구 사양 0~15 지식을 기반으로 작성하기 때문) | Pyxel 3의 V는 0~127(기본값 100). v13의 진폭 RMS는 230 (v100의 1770 대비 약 13%) | 볼륨은 를 사용한다. SE 후 BGM 재개는 v85 ~ v100 resume=True | |
Node 상속 클래스에서 자신의 처리를 def update(self)에 작성함 | pyxel.cube.Node가 원래 가지고 있는 update() (노드 업데이트)를 덮어씀 | 예외는 발생하지 않음. 다만 self.parent = ... 같은 경우는 AttributeError (not writable)가 발생하여 인지할 수 있음 | 자신의 처리는 animate() 등 다른 이름으로 지정한다. 충돌 판정은 def on_collide(self, other, contact):를 오버라이드 한다.<br>무거운 3D 정점 계산(CPU 스키닝 등)을 update()에 작성함 |
이 외에도 예외가 발생하지만 놓치기 쉬운 것 중 하나는 게임 도중에 만든 캐릭터가 자신의 update() 호출보다 먼저 draw()되어 사라지는 경우입니다. (포즈 데이터가 아직 없기 때문에 AttributeError가 발생합니다.) 이 문제를 방지하려면, 캐릭터를 생성할 때 포즈를 한 번 계산해 두면 됩니다. 마지막 항목은 3D에서 캐릭터를 늘리면서 처음 발견한 현상입니다.
원인은 Pyxel이 1 프레임 지연될 경우, 다음 렌더링 전에 update()를 연속으로 두 번 호출하여 따라잡으려고 시도하기 때문이라고 생각합니다 (Pyxel 자체 소스까지는 확인하지 못했습니다. 실측 수치로 추정한 것입니다).
- update()에 스키닝 계산(skinning calculation)을 11.6ms 할당하면, 한 번 지연되는 순간
11.6 × 2 + 15.4 = 38.6 ms가 됩니다. (> 33.3 ms)
- 그러면 다음 프레임도 지연되고, 영원히 “2회 update → 1회 draw”의 16 fps 상태에서 빠져나올 수 없게 됩니다.
- 그래서, update()에서는 가벼운 포즈 시간 계산(0.3ms)만 수행하도록 수정하고, 무거운 정점 계산(vertex calculation)은 draw() 측에서 프레임당 한 번만 호출되도록 했습니다. 이렇게 하면 지연된 프레임에서도 무거운 계산이 두 번 발생하지 않는다는 개념입니다.
공식의 pyxel-mcp는 1.4.0 (2026-11-12) 버전에서 Pyxel 3 및 Cube를 지원하게 되었습니다. 이는 AI 에이전트가 화면 없이 Pyxel 게임을 구동하고, 키 입력을 보내며, 화면이나 변수를 ‘사실’로 받아들일 수 있게 해주는 MCP 서버입니다.
소스 코드(예: validation_patterns.py)를 읽고 확인한 8가지 도구의 역할 범위는 다음과 같습니다.
| 도구 | 2D 게임에서의 역할 | Pyxel Cube (3D)에서 사용 가능 여부 |
|---|
| validate | 코드를 실행하지 않고 10가지 안티 패턴(missing_colkey, update_in_draw, cls_missing, degree_radian_mix 등)을 감지 | △ 10개 항목 모두 2D 전용. 제1장의 Cube 고유 함정(예: pyxel.colors = 직접 할당 등)은 감지하지 못함 |
| run | 헤드리스 상태로 프레임을 진행하며, inputs (키 입력), until (정지 조건), screen_image / state / video를 획득 | ◎ 그대로 사용 가능. scene.camera.transform.pos.x와 같은 수치 경로도 state로 읽을 수 있음 |
| pyxel_info | 버전을 반환하고, 타입 정의(.pyi)의 위치, 샘플 목록을 제공 | ◎ cube_stubs_path 및 Cube 샘플 6개(c01 ~ c06)가 반환됨 |
| read_palette / read_image / read_tilemap | 팔레트, 이미지 뱅크, 타일맵을 검사 | △ 2D 리소스 전용. GLB의 메시나 3D 씬 그래프는 읽지 않음 (README에 명기). |
| read_audio / diff_frames | 소리를 WAV로 변환하여 측정하거나, 두 장의 화면 PNG 간 차이를 비교 | ◎ MML의 무음 체크 또는 3D 렌더링 전후 비교에 그대로 사용 가능 |
즉, “게임을 구동하여 화면과 좌표를 확인하는 부분”은 pyxel-mcp의 run이 그대로 사용 가능하며, “GLB 에셋 내부 내용”과 “Cube 작성 규칙”만 직접 보완하면 된다는 분담 구조가 됩니다.
AI에게 3D 모델을 보여줄 때 정면 단일 이미지로는 ‘뒷면의 결함’이나 ‘파츠의 떠 있음/깊이 불일치’를 놓칠 수 있습니다. 그래서 전, 우, 후, 상 4방향을 1.5m 기준 폴과 함께 하나의 PNG에 나열하는 작은 뷰어(look.py)를 준비했습니다.
예를 들어 3D 마을에서 캐릭터를 걸어 다니게 하며 나무 상자 앞에서 제대로 멈추는지 확인할 때는, 90 프레임 동안 위쪽 키를 계속 누른 상태로 멈춘 좌표를 확인합니다 (pyxel3_asset_gen/preview.py --walk 90 또는 pyxel-mcp의 run에서 inputs와 state를 지정).
-
나무 상자 중심: z = 2.5 m, 깊이 반경: 0.52 m
-
캐릭터의 충돌 판정 반경:
0.25 m
-
계산상 정지 위치:
2.5 + 0.52 + 0.25 = 3.27 m
-
실측 정지 위치: (완벽하게 일치) z = 3.27 m
사람이 매번 창을 열어 손으로 움직여 주지 않아도, “90 프레임 동안 걸으면 z == 3.27에서 멈추고 화면은 이렇게 된다”는 사실만으로 AI가 스스로 확인할 수 있습니다.
게임 속에서 가장 까다로운 것이 배경에 배치하는 3D 소품(나무, 통, 마차, 가로등, 간판 등)입니다. AI나 도구를 사용해서 어디까지 자동화할 수 있을지 세 가지 방식을 만들어 동일한 주제로 비교했습니다.
처음에는 AI에게 직접 3D의 정점(vertex)을 그리게 하는 것이 아니라, 상자나 원통을 조합하는 JSON 설계도를 작성하게 하고, 검사 프로그램(check_spec.py)이 문제를 영어로 지적하며 최대 3회 수정하도록 시스템을 만들었습니다 (로컬 Ollama qwen3.6:35b 사용).
여기서 가장 큰 교훈은 **“검사를 통과하는 것 = 올바른 형태가 아니다”**라는 것이었습니다. ‘오래된 나무 마차’를 만들게 했을 때의 6가지 변화 과정이 이것입니다.
| 시도 | 설계도 사양 및 체커 | 시간 | 결과 (발생한 일) |
|---|
| 1~2회 차 | 회전 rot: [rx, ry, rz]와 size: [x, y, z]를 자유롭게 작성하게 함 | 19초 | 축을 rot: [90, 0, 0]으로 세워서 바닥에 0.2 m 잠기게 함. 지적하자, 검사를 통과하기 위해서만 부품을 움직여서, 축이 세로 막대/바퀴가 누워있는 미스터리한 물체로 통과함 |
| 3회 차 | 회전 계산을 금지하고, `axis: | | |
폴리곤 수가 너무 많음: 브라우저에서 추출한 GLB 파일은 204 부품・39,976 삼각형・711 KB에 달하여, 작은 소품 하나로는 지나치게 무겁습니다. -
프롬프트로 수치 제한을 걸면 사고가 루프함: '삼각형 1,500 이하, 원은 8분할까지'와 같이 세부적인 수치 조건을 추가하여 요청했더니, 28분(1,677초・10만 토큰 초과) 동안 계속 생각만 하다가 답변을 내놓지 못했습니다.
그래서 "AI에게는 '심플하고 투박한 로우폴리곤, 무늬 없는 단색'이라는 한마디만 지시하고, 출력된 HTML을 Node.js로 구동할 때 백그라운드에서 지오메트리를 거칠게 대체하는" 파이프라인(run_html.cjs + fix_glb.py)을 만들었습니다.
| 수레 변환 단계 | 부품(메시) 수 | 삼각형 수 | 파일 크기 |
|---|
| 그대로 브라우저에서 추출 | 204 | 39,976 | 711 KB |
버텍스 클러스터링(--grid 0.05 = 5 cm 격자) | 5 | 4,112 | 79 KB |
run_html.cjs (--round 6 --min 0.12) + fix_glb.py | 5 | 1,992 (약 1/20) | 42 KB |
| 2번째 배치(간결 프롬프트 102초 + 동일 처리) | 3 | 1,740 | 35 KB |
실제로 같은 '오래된 나무 수레'를 Pyxel 3.0.1로 그려 비교한 결과입니다. 방식 A의 시행착오부터 방식 B의 1/20 면 분할까지의 차이가 한눈에 보입니다.
AI에게 "폴리곤을 줄여줘"라고 고민하게 시키기보다는, AI가 잘하는 방식으로 자유롭게 그리게 하고, 프로그램 측에서 "둥근 부품은 육각형으로, 12cm 미만의 장식은 버리고, 같은 색상은 1메시로 통합한다"와 같이 기계적으로 줄여나가는 방식이 생성 시간도 끊기지 않고, 삼각형 수도 1/20까지 줄였습니다.
로컬 LLM 실험에서 하나당 1~3분이 걸리는 것을 보고, "애초에 게임 배경 소품을 매번 LLM에게 몇 분 동안 생각하게 할 필요가 있을까?"라는 의문을 품게 되었습니다.
Pyxel에는 처음부터 도트 그림을 그릴 수 있는 에디터(.pyxres)가 있습니다. 그래서 **16x16의 도트 그림을 앞뒤로 밀어내고, 가려지는 안쪽 면은 제외하며, 같은 평면・같은 색상의 면을 큰 직사각형으로 통합하는 도구(pixel_shape.py)**를 만들었습니다(Kenney Shape과 동일한 개념의 Pyxel 전용 버전입니다).
--depth 2: 같은 두께로 밀어내기 (검, 간판, 울타리 등) -
--puff 8: 윤곽선으로부터의 거리에 따라 중앙 부분이 더 두껍게 부풀리기 (술통, 소나무, 바위 등)
| 소품(원본 도트 그림) | 옵션 | 삼각형 수 | 생성 시간 |
|---|
| 검(16x16) | --depth 1 | 334 | < 0.05초 |
| 술통(12x14) | --puff 8 | 560 | < 0.05초 |
| 소나무(16x16) | --puff 6 | 952 | < 0.05초 |
.pyxres의 도트 그림 2종을 게임 시작 시 직접 3D로 변환하기 | 브라우저 (Code Maker) | — | 15 ms |
색상은 처음부터 Pyxel 표준 16색이므로 색상 틀어짐(color shift)이 발생하지 않으며, NumPy조차 사용하지 않는 순수한 Python만으로 시작 시점에 .pyxres에서 직접 3D 노드를 만들 수 있습니다.
| 항목 | 방식 A: JSON 설계도 (LLM) | 방식 B: Three.js 특화 LLM + 면 분할 | 방식 C: 도트 그림 → 3D (pixel_shape) |
|---|
| 개당 시간 | 1회 8~32초 (수정이 필요하면 그 횟수만큼) | 100~170초 (Node 변환은 0.09초) | 0.05초 이하 |
더 나아가, "브라우저에서 구동되는 Pyxel 3 게임 옆에 채팅창을 배치하고, **서버가 필요 없는 브라우저 내 AI(WebGPU / WASM)**로 그 자리에서 게임을 개조할 수 없을까?"라는 실험도 진행했습니다.
결론적으로, 현시점에서는 실용적이지 않아 중단했습니다. 실제로 3단계 모델을 구동하여 확인한 결과는 다음과 같습니다.
| 테스트한 모델 클래스 | 구현/양자화 | 브라우저에서의 결과 (Pyodide + NumPy 공존 시) |
|---|
초소형(0.35B~0.8B) LFM2-350M-ONNX 등 | Transformers.js / ONNX | 작동은 가볍지만, 지시 추종 및 JSON 출력의 정확도가 낮아 의도한 대로 변경되지 않음 |
중형(1.5B3B) 1.5B3B q4f16 | Transformers.js / ONNX Runtime Web | 크래시 발생. Pyodide와 NumPy가 작동하는 페이지에서는 WASM 측 메모리 할당 상한에 걸리는 ERROR_CODE: 6, ERROR_MESSAGE: std::bad_alloc |
경량 상위(4B) Qwen3.5-4B-q4f16_1-MLC | WebLLM (@mlc-ai/web-llm) | 청크 단위의 WebGPU 스트리밍으로 bad_alloc을 회피하여 작동했지만, 최초 다운로드(수 GB) 및 로딩 시간이 너무 길어 웹 게임으로 배포하기에는 비현실적이라고 판단했습니다. |
브라우저 안에 억지로 작은 AI를 채워 넣는 것보다, 사용자가 평소 사용하는 똑똑한 AI(Claude / ChatGPT / Gemini 등)에게 main.py와 함께 짧은 규칙표를 전달하는 것이 대기 시간도 없고 메모리 부족 문제도 없어 현실적이라고 판단했습니다.
AI에게 처음 전달하는 '최소 규칙표'는 이 정도입니다. 이는 제1장에서 언급한, 예외가 발생하지 않는 버그를 방지하기 위함입니다.
AI에게 처음 전달하는 Pyxel 3 (pyxel.cube) 규칙표
1. 팔레트 변경:
`pyxel.colors = [...]`는 금지(엔진으로부터 분리됨).
반드시 슬라이스 할당 `pyxel.colors[:] = [...]`를 사용해야 합니다.
...
만든 3D 게임을 브라우저의 Pyxel Code Maker에서 열어 작동시킬 때도 알아야 할 두 가지 사양이 있었습니다.
Code Maker의 소스(web/code-maker/)를 읽고 확인해 본 결과, 프로젝트 ZIP에는 필수적인 main.py와 my_resource.pyxres뿐만 아니라, .glb, .tmx, .png, 추가적인 .py 파일까지 모두 로드되며 저장 시에도 그대로 다시 쓰여집니다 (loadProjectFromZip / buildArchiveBlob). 다만, ZIP의 바로 아래에 main.py를 두면 Load failed: Missing main.py가 됩니다.
.pyxapp과 마찬가지로, my_game/main.py처럼 하나의 루트 폴더 안에 모든 파일을 넣어 ZIP으로 만들어야 합니다.
Code Maker 실행 화면은 초기 상태에서는 NumPy를 로드하지 않습니다(그대로 import numpy를 하면 ModuleNotFoundError가 발생합니다).
시작 시점에 pyodide_js.loadPackage("numpy")를 호출하면 로드할 수 있지만, 파일만 배치된 타이밍(find_spec이 통과한 순간)에 import numpy를 하면 C 확장 준비가 완료되지 않아 ImportError로 다운됩니다.
Promise의 then에 create_proxy로 완료 콜백을 전달하고 신호가 온 후에 import하면 확실하게 작동합니다.
import pyxel
try:
import pyodide_js
...
실측 결과, 'LOADING NUMPY...' 표시부터 약 1.2초 만에 VRM 아바타가 움직이기 시작했습니다.
| 확인 사항 | 결과 및 수치 |
|---|
| AI가 Pyxel 3에서 빠지기 쉬운 '예외 없는 함정' | pyxel.colors =의 직접 대입, MML의 V (0~127), def update를 통한 Node 업데이트 덮어쓰기, update() 내 스키닝으로 인한 16 fps 반감 현상 |
| pyxel-mcp (1.4.0)의 방어 범위 |
| run을 이용한 헤드리스(headless) 조작/화면/좌표 관찰은 Cube에서도 유효함. validate (10개 항목)와 에셋 리더는 2D 전용이므로, GLB 체커와 조합해야 함. |
3D 소품 생성: JSON 설계도 (qwen3.6:35b) | 회전 계산을 금지하고 이름 지정 치수 + 자동 스냅 + 중복/앞뒤좌우 검사를 추가하자, 통(barrel)은 1회 / 32초 만에 통과했으며, 수레 역시 3번의 수정으로 수레다운 형태가 되었음. |
3D 소품 생성: Three.js 특화 4B + run_html.cjs | 수치 제약 프롬프트는 28분 동안 루프하므로 엄금함. 단순한 지시(102초) + Node 측의 육각형화/소부품 생략(0.09초)으로 39,976 → 1,992 삼각형 (1/20)을 달성함. |
3D 소품 생성: 도트 그림 압출 (pixel_shape) | AI를 사용하지 않고 .pyxres의 16×16 도트 그림에서 0.05초 이내로 입체화됨. 브라우저에서도 시작 시 2종을 15 ms 만에 생성함. |
| 브라우저 내장 LLM (WebGPU / WASM) | 0.35B는 정확도가 부족하고, 1.5B~3B는 Pyodide와 공존할 때 std::bad_alloc이 발생하며, 4B는 초기 로딩이 너무 무거움. 평소 AI에게 규칙표를 전달하는 형태가 현실적임. |
| Code Maker에서의 3D + NumPy 실행 | 단일 폴더 계층 ZIP으로 만들고, loadPackage("numpy").then(...)로 완료를 기다린 후 import 하면 1.2초 만에 시작됨. |