본문으로 건너뛰기
피드

1993년식 그래픽을 현대 툴로 만들기, 1인 FPS 개발자의 팔레트 지옥 탐험기

general 약 13분
vote
0
댓글
북마크

한 인디 개발자가 Catlantean 3D라는 레트로 FPS를 만들면서 320x240, 256색 팔레트, 소프트웨어 레이캐스터라는 제약 안에서 에셋을 제작하는 과정을 풀었다. 핵심은 단순한 향수가 아니라, 팔레트 기반 조명, Blender 자동 렌더링, Python 텍스처 생성, 전용 맵 에디터까지 이어지는 꽤 탄탄한 제작 파이프라인이다. 제한이 많을수록 픽셀 하나와 도구 하나의 선택이 더 중요해진다는 얘기다.

  • 1

    Catlantean 3D는 90년대 초반 PC FPS 스타일을 목표로 한 320x240 기반 레이캐스터다.

  • 2

    256색 팔레트와 1바이트 픽셀 구조 때문에 조명은 runtime 계산 대신 사전 생성된 colormap으로 처리한다.

  • 3

    Blender와 Python 스크립트를 이용해 모델 애니메이션을 8방향 스프라이트로 자동 렌더링한다.

  • 4

    작은 HUD와 pickup은 Blender보다 손으로 그린 픽셀이 더 낫다고 판단했다.

  • 5

    Tiled 대신 wxPython 기반 전용 맵 에디터를 만들어 라이트 페인팅과 엔진 속성을 직접 다룬다.

  • 한 개발자가 Catlantean 3D라는 레트로 FPS를 만들면서 “1993년식 그래픽을 지금 어떻게 만들까”를 아주 깊게 풀어냄

    • 목표는 90년대 초반 PC FPS 느낌의 완성 가능한 1인칭 슈터임
    • 현대 컴파일러와 플랫폼 추상화 레이어는 쓰지만, 그래픽과 에셋은 당시 제약을 일부러 끌어안음
    • 개발자는 내년 Steam 출시를 목표로 1년 넘게 사이드 프로젝트로 만들고 있음
  • 기반 감성은 VGA Mode 13h인데, 실제 목표 해상도는 320x240에 가까움

    • Mode 13h는 320x200, 256색 그래픽 모드였고 픽셀 하나가 팔레트 인덱스 1바이트였음
    • 320x200을 4:3 화면에 띄우면 non-square pixel 문제가 생김
    • 글쓴이는 더 정사각 픽셀에 가까운 320x240, 즉 VGA Mode-X 느낌을 선택함
    • “정통성”보다 보기 좋은 결과를 고른 셈임
  • 256색 팔레트는 단순한 색상표가 아니라 게임 전체의 룩을 결정하는 768바이트짜리 설계도임

    • RGB 3바이트 × 256색이라 총 768바이트임
    • 현대 게임 에셋처럼 수백만 색을 마음대로 쓰는 게 아니라, 화면의 모든 픽셀이 이 256색 중 하나여야 함
    • 그래서 색 하나하나를 반복 테스트하면서 골라야 하고, 그 제약이 오히려 Doom이나 Duke Nukem 같은 선명한 그래픽을 만든다고 봄
  • Catlantean 3D는 전통적인 raycaster임. 화면의 각 세로줄마다 광선을 쏴서 벽을 찾고 텍스처를 샘플링함

    • 맵은 같은 크기의 tile로 구성되고, 어떤 tile은 벽이고 어떤 tile은 빈 공간임
    • 렌더러는 DDA 알고리즘으로 tilemap을 따라가며 충돌 지점을 찾음
    • 벽은 column 단위로 그리고, 바닥과 천장은 horizontal scanline으로 채움
    • raycasting 자체보다 글쓴이가 집중한 건 보통 블로그에서 덜 다루는 “조명”임
  • 팔레트 렌더러에서 조명은 생각보다 빡셈. RGB 값을 곱해서 어둡게 만들 수 없기 때문임

    • 화면의 픽셀은 실제 색이 아니라 팔레트 인덱스임
    • 어떤 색의 어두운 버전을 찾으려면 팔레트 전체에서 가장 가까운 색을 골라야 함
    • 이걸 매 픽셀마다 하면 너무 느림
    • 그래서 글쓴이는 모든 색의 어두운 단계를 미리 계산한 colormap을 만듦

중요

> 이 엔진의 조명 핵심은 런타임 계산이 아니라 사전 계산임. 256색 × 32단계 shade table을 만들어두고, 렌더링 중에는 거리 기반 인덱스로 바로 lookup하는 식임

  • colormap은 256색 각각에 대해 32단계 어두운 변형을 미리 고른 2D matrix임

    • shade index 0은 원래 색이고, 나머지 31단계는 더 어두운 색임
    • 목표 색은 RGB에 darkening factor를 곱해서 만들지만, 그 색이 팔레트에 없을 수 있음
    • 처음에는 유클리드 거리로 가까운 색을 찾았는데, 결과가 회색 쪽으로 쏠려 차갑고 생기 없어 보였다고 함
    • 그래서 Oklab 색공간의 perceptual distance를 쓰고, 어두워질수록 따뜻한 hue shifting을 약간 적용함
  • 런타임 비용은 O(1) lookup으로 떨어짐. 이게 고전 렌더러다운 맛임

    • 거리로 colormap row를 계산함
    • 원래 색상 인덱스 N과 shade row를 이용해 어두운 색의 팔레트 인덱스를 바로 가져옴
    • 벽은 최대 320번, 바닥은 최대 240번, sprite는 보이는 것마다 한 번 정도만 shade 계산을 줄일 수 있음
    • 픽셀마다 색을 찾는 방식과는 비용 차이가 큼
  • 스프라이트 제작은 Blender와 Python으로 자동화함. 손으로 모든 프레임을 찍는 건 1인 개발자에게 너무 비쌈

    • 모델을 Blender에서 만들고 rigging·animation까지 처리함
    • Python script가 Blender API를 이용해 애니메이션 프레임을 렌더링함
    • 적 스프라이트는 walk, fire, die 같은 animation마다 8방향 프레임이 필요함
    • 스크립트가 모델을 회전시키며 방향별 프레임을 뽑고, sprite name, action, direction, frame index 규칙으로 저장함
  • 렌더링된 이미지는 그대로 쓰지 않고 1바이트 픽셀 TEX 포맷으로 변환함

    • Blender 출력은 truecolor 이미지라 팔레트 제약에 맞지 않음
    • Python script가 각 픽셀을 Oklab 기준으로 가장 가까운 팔레트 색에 quantization함
    • 결과는 팔레트 인덱스 배열과 크기 정보를 담은 단순한 TEX 포맷으로 패킹됨
    • 생성된 스프라이트는 저장소에 넣지 않고 .gitignore 처리하며, 다른 PC에서는 빌드 스크립트로 다시 생성함
    • RTX 3070 기준 약 15개 모델 렌더링이 10초 정도 걸린다고 함
  • 그런데 모든 걸 Blender로 해결하진 않음. 작은 픽셀 에셋은 결국 사람이 찍어야 한다는 결론을 냄

    • 고양이 얼굴 HUD를 Blender로 렌더링했더니 게으르고 영혼 없어 보였다고 함
    • 손으로 그린 버전은 감정 표현과 픽셀 의도가 훨씬 좋아졌음
    • pickup 같은 작은 아이템도 Blender compositor가 안정적으로 좋은 결과를 못 내서 손으로 다듬음
    • 픽셀 크기가 작은 에셋일수록 자동화보다 사람이 찍은 deliberate pixel이 더 중요해짐
  • sprite 해상도를 그냥 키우면 되지 않냐는 질문에는 단호히 “안 됨” 쪽임

    • 게임 월드에서 한 unit은 64픽셀로 고정함
    • 어떤 스프라이트가 world unit의 1/4 높이라면 16픽셀이어야 함
    • 서로 다른 픽셀 스케일의 에셋을 섞으면 화면에서 크기감이 깨져서 싸구려 asset flip 느낌이 난다고 봄
    • 레트로 그래픽은 낮은 해상도가 문제가 아니라, 일관성 없는 픽셀 스케일이 문제라는 얘기임
  • 텍스처는 일부를 Python으로 절차 생성함. 반복 변형을 손으로 그리는 대신 파라미터로 만드는 방식임

    • 벽 같은 텍스처는 기본 재질 위에 마모, 먼지, 표면 디테일이 변형되는 구조임
    • 매번 손으로 그리면 오래 걸리고 일관성도 깨짐
    • script가 여러 입력을 받아 최종 텍스처를 만들고 팔레트 quantization까지 처리함
    • 튜닝은 픽셀을 다시 칠하는 게 아니라 parameter를 조정하는 일이 됨
  • 적이 터지는 gibbing animation도 Python simulation으로 미리 구워둠. 런타임 particle system이 아님

    • sprite의 opaque body에서 무작위 seed pixel K개를 고름
    • 각 픽셀을 가장 가까운 seed에 배정해 Voronoi처럼 chunk를 만듦
    • chunk 경계는 wound로 표시하고, BFS로 안쪽 depth를 계산함
    • 렌더링 시 경계 근처는 팔레트 기반 blood ramp로 섞고, 안쪽은 원래 색을 유지함
    • chunk count, explosion force, gravity, drag, spread, spin, woundDepth 같은 값을 파라미터로 조정함
  • 폭발·플라즈마 같은 효과도 대부분 pre-baked임. 소프트웨어 래스터라이저를 빠르게 유지하려는 선택임

    • runtime particle system 없이 Python script가 PNG frame을 생성함
    • 각 프레임은 radial energy field를 픽셀 단위로 합성해 만듦
    • palette ramp를 이용해 밝기와 색을 정하고, white-hot core 느낌도 threshold로 처리함
    • one-shot 폭발과 seamless loop 효과를 모두 만들 수 있음
  • 맵 에디터는 Tiled에서 시작했지만 결국 직접 만듦

    • Tiled에는 cell별 light level painting, cell flag, 게임 전용 entity property 개념이 없었음
    • 처음에는 object property를 억지로 쓰고, JSON을 엔진 binary format으로 바꾸는 Python 변환 스크립트를 뒀음
    • 하지만 플레이어에게도 에디터를 배포하려면 Tiled 설치와 변환 스크립트까지 요구하는 건 말이 안 된다고 판단함
    • 그래서 wxPython으로 자체 에디터를 만들고, 에디터에서 바로 레벨 실행까지 가능하게 함
  • 에디터 구조는 Python UI와 C++ 엔진 내부가 붙어 있는 작은 생태계임

    • UI는 wxPython으로 만들었고, tkinter보다 widget, event handling, layout이 더 낫다고 평가함
    • MVP pattern으로 UI logic과 map data를 분리함
    • 엔진 내부 기능은 pybast라는 Python binding을 통해 재사용함
    • C++에 이미 있는 로직을 Python에서 다시 구현하지 않으려는 실용적인 선택임
  • 전체적으로 이 글의 교훈은 “레트로는 대충 저해상도 필터가 아니다”에 가까움

    • 팔레트, 조명, 픽셀 스케일, 빌드 파이프라인, 에디터까지 전부 맞아야 그럴듯해짐
    • 자동화할 부분은 Blender와 Python으로 밀어붙이고, 손맛이 필요한 작은 픽셀은 직접 그림
    • 런타임은 단순하게 두고, 빌드 타임에 최대한 많이 계산해둠
    • 1인 개발자가 완성까지 가려면 감성보다 파이프라인이 더 중요하다는 걸 보여주는 글임

기술 맥락

  • 이 프로젝트의 중요한 선택은 런타임을 똑똑하게 만드는 대신 빌드 타임에 많이 구워두는 거예요. 256색 소프트웨어 렌더러에서는 매 픽셀마다 조명과 색 변환을 계산하면 비용이 커지니까, colormap이나 pre-baked effect로 바꿔두는 게 훨씬 맞아요.

  • Blender를 쓰면서도 최종 결과를 1바이트 TEX로 바꾸는 이유는 엔진의 제약을 끝까지 유지하기 위해서예요. 에셋 제작은 현대 툴로 편하게 하되, 게임 안에 들어가는 데이터는 팔레트 인덱스 기반이어야 화면 전체의 룩과 렌더러 단순성이 유지돼요.

  • 작은 HUD나 pickup을 손으로 그린 건 자동화의 한계를 인정한 선택이에요. 픽셀 수가 적을수록 한 픽셀이 표정, 윤곽, 가독성을 크게 바꾸기 때문에 Blender 렌더러가 평균적으로 그럴듯한 결과를 내도 최종 품질은 떨어질 수 있거든요.

  • 자체 맵 에디터를 만든 이유도 단순한 취향이 아니에요. 엔진이 cell light, flag, entity property 같은 도메인 개념을 갖고 있는데 범용 에디터가 그걸 자연스럽게 표현하지 못하면, 변환 스크립트와 우회 규칙이 계속 늘어나요.

  • wxPython과 pybind 기반 엔진 바인딩을 붙인 건 1인 개발자에게 꽤 합리적인 구조예요. UI는 Python으로 빠르게 만들고, 규칙과 데이터 모델은 C++ 엔진 구현을 재사용하면 툴과 게임이 서로 다른 진실을 갖는 문제를 줄일 수 있어요.

이 글은 레트로 그래픽 감성 글처럼 보이지만, 실제로는 제약 기반 엔진에서 제작 효율을 어떻게 설계하는지에 대한 꽤 좋은 사례다. 특히 “런타임을 단순하게 만들기 위해 빌드 타임에 최대한 굽는다”는 전략은 게임뿐 아니라 임베디드·툴링 개발에서도 그대로 통한다.

댓글

댓글

댓글을 불러오는 중...

general

AI가 앱을 무한정 찍어내도, 결국 부족한 건 코드가 아니라 관심이다

AI 에이전트 이후 앱 출시와 커밋은 폭증했지만 실제 사용량은 거의 늘지 않았다는 분석이다. 글의 핵심은 개발자 생산성을 코드 생성량으로 보면 착시가 생기고, 제품을 선택받게 만드는 신뢰·문서·커뮤니티·고투마켓 역량이 더 희소해졌다는 얘기다.

general

유니젯, 유럽 기업에 AI 글라스용 웨이브가이드 인쇄 공정 수주

유니젯이 유럽 디스플레이·광학 솔루션 장비 기업으로부터 AI 글라스용 고정밀 인쇄 공정 기술을 수주했다. 대상은 웨이브가이드 표면에 미세 패턴을 인쇄하는 공정으로, AI 글라스의 시야각과 밝기, 선명도에 직접 영향을 주는 부품이다.

general

공원 만들라고 기부한 땅, 26년 뒤 데이터센터 부지로 팔림

텍사스 테일러시가 1999년 공원 용도로 기부받은 87에이커 땅을 2025년에 데이터센터 개발사에 1천만 달러에 매각했어. 원래 지역 주민을 위한 공공 공간이 될 예정이던 땅에는 이제 13만5천 제곱피트 규모 데이터센터가 들어설 예정이야.

general

맥에서 재생 버튼 누를 때 애플 뮤직 대신 스포티파이 띄우기

맥에서 키보드 재생 버튼을 눌렀을 때 애플 뮤직이 자동으로 뜨는 문제를 우회하는 작은 유틸리티 얘기다. Music Decoy는 macOS의 미디어 키 동작을 완전히 죽이지 않고, 원하는 앱을 대신 실행하도록 설정할 수 있게 해준다.

general

소상공인 AI 지원, 플랫폼 보급보다 현장에서 바로 쓰게 만드는 게 먼저다

소상공인 대상 AI 지원이 공급자 중심 플랫폼 보급과 일회성 예산 집행에 머물러 실제 체감 효과가 낮다는 지적이다. 글은 기존 POS나 매장 환경에 붙여 쓰는 구독형 SaaS, 도입 이후 유지보수, 실무형 교육으로 정책 방향을 바꿔야 한다고 주장한다. 핵심은 거창한 AI가 아니라 오늘 바로 30분을 아껴주는 효용이다.