- Catlantean 3D는 1990년대 초반 PC 게임식 제약으로 완성형 1인칭 슈터를 만들려는 사이드 프로젝트이며, 320x240 해상도와 256색 팔레트 기반 렌더링을 목표로 함
- 렌더러는 팔레트 인덱스만 다루기 때문에 거리 기반 어둡게 보이기를 위해 32단계 colormap을 사전 계산하고, 실행 중에는 O(1) 조회로 어두운 색을 선택함
- 애셋 제작은 Blender 기반 사전 렌더링 스프라이트, 손그림 스프라이트·텍스처, Python 스크립트로 생성하는 절차적 텍스처로 나뉨
- 손그림 HUD와 픽셀 단위 스케일 규칙은 작은 해상도에서 선명도와 가독성을 유지하기 위한 핵심 제약이며, 월드 1유닛을 64픽셀 기준으로 맞춤
- Tiled 대신 자체 맵 에디터를 만들고, 출시 후 플레이어에게도 같은 에디터를 제공할 계획이며, 게임 소스 코드는 GitHub에 오픈소스로 공개할 예정임
프로젝트 목표와 제약
- Catlantean 3D는 1년 넘게 여가 시간에 천천히 개발 중인 사이드 프로젝트이며, 다음 해 Steam 출시를 목표로 함
- 목표는 1990년대 초반에 흔했던 기법으로 완성되어 출시 가능한 1인칭 슈터를 만드는 것임
- 현대 컴파일러와 플랫폼 추상화 계층은 허용하지만, 추상화 계층은 픽셀을 쓰는 프레임버퍼, 키보드·마우스 입력, 샘플을 쓰는 오디오 버퍼, 파일시스템 I/O 정도로 제한함
- 게임은 애셋까지 전부 처음부터 만들어야 하고, 렌더링과 사운드 믹싱도 모두 직접 구현해야 함
- 목표 해상도는 320x240이고, 화면의 모든 픽셀은 256색 중 하나만 사용할 수 있음
- 게임 로직은 결정적 동작 보장을 위해 고정소수점을 사용하고, 렌더링은 결정성이 덜 중요해 부동소수점을 사용함
- 결과물은 기술 데모가 아니라 재미있게 플레이할 수 있는 완성도 있는 게임이어야 하며, AI 산출물은 사용하지 않음
- 표시된 모든 작업물은 작업 중인 상태이며 크게 바뀔 수 있음
팔레트 렌더링
-
VGA 그래픽
- VGA 하드웨어의 Mode 13h는 320x200 256색 그래픽 모드였고, 한 세대의 PC 게임을 규정한 유명한 모드였음
- 프로그래머 관점에서 Mode 13h는 각 픽셀이 256색 팔레트의 인덱스인 1바이트로 표현되는 선형 프레임버퍼를 제공함
- 픽셀을 그리려면 특정 주소에 1바이트를 쓰면 되었고, 셰이더나 VRAM 같은 개념을 다루지 않아도 됨
- 최신 게임 애셋은 이미지에 수백만 색을 사용할 수 있지만, 256색 제한에서는 모든 색 선택이 신중하고 의도적이어야 함
- Doom과 Duke Nukem 같은 게임은 기술적 한계 때문에 그래픽의 선명함과 명료함이 생긴 사례로 제시됨
- Catlantean 3D는 그 감각을 재현하려 하지만, 320x200 대신 VGA Mode-X에 가까운 320x240을 선택함
- 320x200을 4:3 디스플레이에 표시하면 정사각형이 아닌 픽셀이 되며, 이 방식은 더 정통적이지만 선호에 따라 피함
-
팔레트
- 팔레트는 768바이트에서 시작하며, 여러 번의 시행착오와 반복을 거쳐 선택됨
- 팔레트에는 투명색용 선명한 분홍색, 순백색, 순검정색이 각각 하나씩 예약됨
- 피 표현을 위해 빨간색 계열이 많이 필요했고, 빨강·초록·파랑 키와 색상 구분 문을 위해 초록색과 파란색 계열도 필요함
- 배경은 고양이 숭배 때문에 고대 이집트와 닮은 패러디 땅 Catlantis로 설정되어 노랑과 갈색 중심의 사막색이 많이 필요함
- Catlantis가 사이버네틱 개 인간에게 점령된 설정이어서 기술 시설 표현을 위한 회색 계열이 많이 필요함
- 회색의 단조로움을 깨고 어두워질 때 따뜻한 대체색으로 쓰기 위해 베이지색 계열도 들어감
- 나머지 색은 텍스처 제작 과정에서 필요할 때 채워졌고, “보기에 맞았다”는 주관적 판단에 따라 정해짐
- 팔레트는 한 번에 완성되지 않았고, 애셋 제작과 테스트, 반복 과정에서 계속 조정됨
colormap과 조명 처리
-
raycaster 구조
- Catlantean 3D는 전통적인 raycaster이며, 맵은 모두 같은 크기의 타일로 구성됨
- 일부 타일은 벽이고, 다른 타일은 바닥과 천장만 있는 빈 공간임
- 렌더러는 화면의 각 열마다 DDA 알고리듬을 사용해 타일맵을 지나가며 맵 지오메트리와 부딪히는 위치를 찾음
- 충돌 위치에 따라 적절한 텍스처 좌표에서 샘플링한 벽 열을 화면에 그림
- 바닥과 천장은 이후 수평 스캔라인으로 렌더링되어 화면의 나머지를 채움
- 단순히 팔레트만으로 게임 월드를 렌더링하면 평평하고 인상적이지 않은 화면이 됨
- 플레이어에게서 멀어질수록 빛이 줄어들고, 맵 타일의 한쪽 면이 다른 쪽보다 약간 어두우면 깊이감이 생김
-
팔레트 기반 어둡게 하기
- 현대 하드웨어 가속 렌더러에서는 정점 거리 기반으로 색 벡터에 부동소수점 계수를 곱해 셰이더에서 쉽게 어둡게 만들 수 있음
- 팔레트 렌더러는 색 개념이 아니라 팔레트 인덱스만 다루므로, 특정 색의 더 어두운 색을 찾으려면 팔레트 전체를 탐색해야 함
- 모든 렌더링 픽셀마다 256색 팔레트를 탐색하면 너무 느리기 때문에, 실행 전 사전 계산으로 빠른 색 조회를 준비함
- 팔레트를 한 행으로 놓고 32개 명암 단계를 선택하면, 각 색마다 원본을 제외한 31개의 더 어두운 변형이 필요함
- 각 색의 RGB 값과 명암 인덱스로 목표 어두운 색을 계산하지만, 그 색이 실제 팔레트에 존재하지 않을 수 있음
- 목표 색과 가장 가까운 팔레트 색을 찾기 위해 팔레트를 탐색해 colormap을 구성함
- 초기에는 유클리드 거리를 사용했지만, 많은 색이 회색 쪽으로 끌리는 경향이 있었고 어두운 색이 차갑고 생기 없어 보였음
- 이후 색을 Oklab 색공간으로 변환하고, 인간의 색 차이 지각에 더 가까운 지각 거리 공식을 사용함
- 색이 어두워질수록 따뜻한 색조로 조금 이동시키는 픽셀 아트 개념인 색조 이동(hue shifting)도 적용함
- colormap은 각 색의 명암을 나타내는 팔레트 인덱스의 2차원 행렬이며, 여전히 팔레트 색만 사용할 수 있어 그라데이션은 완벽하지 않음
-
실행 비용 절감
- 거리 기반 colormap 행 인덱스가 정해지면, 해당 명암 단계 행에서 N번째 항목을 골라 어두워진 색 N의 팔레트 인덱스를 얻음
- 이 방식은 실행 중 더 어두운 색 선택을 O(1) 조회로 처리함
- 벽 렌더링에서는 벽 열이 완전히 수직이고 열 안의 모든 픽셀이 카메라와 같은 거리를 가지므로 화면 열마다 한 번만 colormap 행 인덱스를 계산함
- 바닥 렌더링에서는 수평 행의 모든 픽셀이 같은 거리를 가지므로 화면 행마다 한 번만 계산함
- 스프라이트는 모든 픽셀이 카메라와 같은 거리를 갖는 평평한 빌보드이므로 보이는 스프라이트마다 한 번만 계산함
- 벽은 320번, 바닥은 최대 240번, 보이는 스프라이트는 각각 한 번만 계산하면 되며, raycasting은 가려진 객체 제거를 무료로 제공함
- Doom과 여러 다른 게임도 비슷한 접근을 사용함
애셋 제작 방식
-
세 가지 애셋 범주
- Catlantean 3D의 텍스처와 스프라이트는 세 범주로 나뉨
- 첫 번째 범주는 Blender에서 만든 3D 모델을 텍스처로 렌더링한 사전 렌더링 스프라이트임
- 두 번째 범주는 손으로 그린 스프라이트와 텍스처임
- 세 번째 범주는 손그림 아트를 조합하는 특수 Python 스크립트로 생성한 절차적 텍스처임
-
사전 렌더링 스프라이트
- 복잡한 애니메이션 스프라이트는 여러 프레임을 수정해야 하므로 반복 작업이 어렵고 시간이 많이 듦
- 더 효율적인 방식은 Blender에서 3D 모델을 만들고 리깅과 애니메이션을 수행한 뒤, Blender Python API를 쓰는 스크립트로 여러 텍스처를 렌더링하는 것임
- 수정은 모델에서 이루어지고 렌더링 스크립트가 나머지를 처리하므로 반복 작업 시간이 크게 줄어듦
- 주요 난점은 렌더링된 스프라이트가 매우 흐리고 색이 바랜 것처럼 나온다는 점이었음
- 고해상도로 렌더링한 뒤 필터링으로 축소하는 방식은 디테일이 필터링에 눌리고 가장자리 선명도가 사라지는 경우가 있어 성과가 섞였음
- 가장 효과적이고 재사용 가능한 방식은 Blender의 합성 기능으로 적절한 대비와 선명도를 얻는 것이었음
- 이미지가 준비되면 특수 Python 스크립트가 팔레트 양자화를 수행해 엔진이 사용하는 1바이트 픽셀 이미지를 생성함
- 스크립트는 원본 이미지의 각 픽셀마다 Oklab 기준으로 지각적으로 가장 가까운 팔레트 색을 찾고, 그 색의 인덱스를 픽셀 값으로 사용함
- 인덱스 배열과 크기 정보는 게임에서 쓰는 단순한 TEX 형식으로 패킹됨
- 적 스프라이트는 여러 애니메이션을 가질 수 있고, 각 애니메이션은 스프라이트가 향할 수 있는 8방향의 프레임을 가져야 함
- Python 스크립트는 애니메이션마다 스프라이트를 회전시키고 모든 프레임을 렌더링한 뒤 다시 회전하는 과정을 반복함
- 스프라이트 파일명은 스프라이트 이름, 동작 이름, 방향, 프레임 인덱스를 나타내는 규칙으로 저장됨
- 렌더링된 스프라이트는 저장소에 두지 않고 .gitignore 처리되며, 다른 컴퓨터에서는 컴파일 스크립트가 모든 모델을 렌더링해 스프라이트를 생성함
- RTX 3070에서 약 15개 모델을 처리하는 데 약 10초가 걸림
-
손그림 스프라이트와 텍스처
- 개발 초기에 상태바 얼굴로 쓰기 위해 고양이 Vilko의 텍스처를 입힌 고양이 모양 머리를 Blender로 만들었음
- 이 결과물은 게으르고 낮은 노력처럼 보였고, 감정을 잘 전달하지 못했으며, 분위기 피드백에서 사람들이 가장 먼저 지적한 부분이었음
- 일부 요소는 반드시 손으로 그려야 하며, 애니메이션이 들어간 손그림 버전이 훨씬 낫다고 판단됨
- 스프라이트 크기 때문에 모든 픽셀이 의도적이어야 하며, Blender 렌더러에 맡길 여지가 없음
- 대부분의 픽업 아이템에도 같은 논리가 적용됐고, 이전 사전 렌더링 결과물은 작은 스케일에서 Blender 합성기가 안정적으로 좋은 결과를 내지 못함
- 사람의 손을 거친 뒤 픽업 아이템의 선명도와 가독성이 크게 좋아짐
- 스프라이트 해상도를 단순히 높이면 게임 래스터라이저가 스케일링할 수는 있지만, 픽셀 스케일이 일관되지 않아 결과가 좋지 않음
- 화면의 같은 행이나 열에서 앞뒤로 움직일 때 픽셀 크기가 같게 유지된다고 무의식적으로 기대하므로, 스프라이트마다 픽셀 스케일이 다르면 어색해짐
- Catlantean 3D의 월드 1유닛은 64픽셀이며, 모든 스프라이트는 이 스케일에 맞춰 제작됨
- 월드 유닛의 4분의 1 높이 스프라이트는 64/4=16픽셀 높이여야 함
HUD와 절차적 생성 파이프라인
-
HUD
- HUD와 그 구성 요소는 거의 전부 손으로 배치하고 그림
- 화면 하단 상태바, 여러 전환 패널과 화면, 폰트가 손그림 HUD 범주에 들어감
- 모든 요소를 직접 칠하기보다 Affinity Photo의 레이어 효과와 합성을 많이 사용함
- 사용한 효과에는 평평한 표면의 3D 느낌을 내는 emboss 효과, 거친 느낌을 위한 노이즈 생성과 오버레이, 색상 오버레이, 블렌딩 모드, glow 효과가 있음
- HUD 요소를 자주 반복 수정하므로 레이어 기반 재배치 편의성도 중요함
- 일반적으로 Affinity Photo에서 truecolor로 먼저 작업하며, 많은 요소는 단색 사각형 위에 특수 효과와 블렌딩을 적용한 것임
- Affinity Photo에서 내보낸 이미지는 안티앨리어싱과 관련된 것으로 보이는 이상한 아티팩트를 포함했고, 이를 안정적으로 끄지 못함
- 픽셀 정확한 작업에는 적합하지 않아 Aseprite에서 픽셀 퍼펙트 텍스트, 아트워크 조각내기, 더 선명한 경계선 덧칠 같은 추가 작업을 수행함
-
절차적 생성 텍스처
- 일부 텍스처는 직접 그리기 충분히 단순하거나 구체적이지만, 많은 텍스처는 기본 재질 위에 마모, 먼지, 표면 디테일 변형을 공유함
- 각 변형을 손으로 그리면 지루하고 일관성이 떨어지므로 Python 스크립트로 생성함
- 생성 파이프라인은 표면 relief를 정의하는 heightmap, 변형용 noise map, 먼지와 마모용 grime map, 두 가지 기본색, brightmap을 입력으로 받음
- heightmap은 실제로 normal map을 만드는 데 쓰이고, normal map은 단순한 조명과 그림자를 굽는 데 사용됨
- brightmap은 다른 매개변수와 관계없이 색을 유지할 부분을 지정함
- 스크립트는 최종 텍스처를 만들고 팔레트 양자화까지 수행해 엔진에서 바로 쓸 수 있게 함
- 텍스처 수정은 픽셀을 다시 그리는 대신 매개변수를 조정하는 일이 되며, 혼자 작업할 때 시간을 크게 절약함
gibs와 사전 렌더링 효과
-
Gibs
- 적에게 point-blank shotgun blast나 explosion 같은 과도한 피해가 적용되면 gibbing이 일반적으로 발생함
- 큰 피해의 충격을 전달하기 위해 적이 피 묻은 조각으로 터져 나가는 애니메이션을 사용함
- 이 파이프라인은 Python 스크립트가 주도하며, 스프라이트, 팔레트, 매개변수 집합을 받아 게임 데이터에 들어갈 애니메이션 프레임을 생성함
- 첫 단계인 Voronoi decomposition에서는 스프라이트의 불투명 몸체에서 K개 seed 픽셀을 무작위로 고르고, 모든 픽셀을 가장 가까운 seed에 할당함
- 이렇게 생긴 각 cell은 날아가는 조각 하나가 됨
- 두 번째 단계인 wound bleeding에서는 서로 다른 조각과 인접한 경계 픽셀을 깊이 0의 상처로 표시하고, BFS가 안쪽으로 퍼지며 깊이 값을 부여함
- 렌더링 시 경계 근처 픽셀은 게임 팔레트에서 파생한 ramp의 피 색상 쪽으로 섞이고, 조각 안쪽으로 들어갈수록 원래 스프라이트 색이 더 보존됨
- 팔레트 ramp 선택은 매개변수화되어 특정 적에게 초록색이나 파란색 “피”도 사용할 수 있음
- 세 번째 단계인 physics에서는 각 조각에 중심점, 스프라이트 중심에서 바깥으로 향하는 무작위 확산 속도, 회전 속도, 중력, drag를 부여함
- 충돌 감지는 없지만 조각은 바닥과 부딪히면 멈추며, 조잡하지만 충분한 결과를 냄
- 조각 수, 폭발 힘, 중력, drag, 확산, 상처 깊이는 매개변수로 조정 가능함
- 보기 좋은 seed를 찾는 데 약간의 시행착오가 필요하지만, 손으로 애니메이션을 그리는 것보다 빠름
- 같은 기법은 화분, 배럴, 상자 같은 파괴 가능한 환경 오브젝트에도 사용됨
- 사전 렌더링 애니메이션처럼 gibs 결과물도 저장소에 두지 않고, 체크아웃 후 다시 생성되며 실행 시간은 무시할 만함
-
사전 렌더링 파티클 시스템
- 대부분의 파티클 효과는 Aseprite에서 손으로 그리지만, 일부는 gibs와 같은 방식으로 생성하고 굽음
- Python 스크립트가 시뮬레이션을 실행해 PNG 프레임 시퀀스를 만들고, 이후 TEX로 양자화함
- 런타임 파티클 시스템은 없으며, 모든 효과를 미리 구워 소프트웨어 래스터라이저가 최대한 빠르게 렌더링할 수 있게 함
- 여기서 “particle”이라는 단어는 다소 오해의 소지가 있으며, 실제로는 입자를 시뮬레이션하지 않음
- 각 프레임은 픽셀별 radial energy field를 계산하고 여러 독립 레이어를 합산해 합성됨
- core는 애니메이션 동안 바깥으로 확장되는 부드러운 원반임
- rays는 core 주변의 뾰족한 빛줄기이며, sharpness와 length를 설정할 수 있고 각 ray에는 RNG 기반 길이 흔들림이 들어가 불규칙해 보임
- ring은 선택 가능한 확장 shockwave이고, noise는 전체 에너지에 value noise를 곱해 깨끗한 형태를 거칠고 불규칙하게 만듦
- 누적된 픽셀별 에너지는 스크립트 매개변수로 지정한 팔레트 ramp에 맞춰 양자화됨
- 팔레트 설계상 각 행이 밝음에서 어두움으로 이어지는 그라데이션처럼 다뤄지므로, 블렌딩이나 알파 계산 없이 팔레트 인덱스 산술만으로 픽셀을 어둡게 함
- 특정 임계값 이상에서는 픽셀을 흰색 쪽으로 밀어 white-hot core 같은 인상을 줌
- 선택적으로 작은 sparkle을 위에 흩뿌릴 수 있으며, 이 십자 모양은 바깥으로 이동하고 각자 수명 동안 사라짐
- 애니메이션은 explosion이나 teleport flash처럼 커졌다가 사라지는 one-shot 모드와, 첫 프레임과 마지막 프레임이 맞아 끊김 없는 loop 모드를 지원함
- loop 모드는 plasma bolts와 energy projectiles 같은 지속 반복 효과에 유용함
맵 에디터와 도구 생태계
- 맵 편집은 처음에 Tiled에서 시작했으며, 일반적으로 합리적인 도구였지만 게임에 필요한 구체적 기능이 부족했음
- Tiled에는 셀별 light level painting, cell flags, 게임 고유 속성 개념이 없어서 초기에는 object properties를 남용해 우회함
- Tiled의 JSON 출력을 엔진이 쓰는 바이너리 형식으로 변환하는 Python 스크립트도 필요했으며, 이는 도구와 게임 요구사항의 불일치를 보완하기 위한 추가 구성 요소였음
- 플레이어가 맵을 만들기 위해 Tiled를 설치하고 인터페이스를 배우고 변환 스크립트를 설정해야 한다면, 에디터가 실제로 쓰일 가능성을 없앨 수준으로 부담이 큼
- 자체 에디터는 light level painting, cell flags, 게임이 아는 모든 entity와 property 타입을 네이티브로 지원함
- 게임이 출시되면 플레이어도 개발에 사용한 같은 에디터를 받게 됨
- 에디터는 plug and play 방식이며, 에디터에서 곧바로 레벨을 실행할 수 있음
- 툴바 아이콘이 형편없다는 점을 알고 있으며, 바로 그 이유로 그대로 유지함
- 에디터는 wxPython으로 만들어졌고, tkinter보다 widget, event handling, layout에서 더 잘 맞았음
- wxPython 결과물은 더 네이티브하게 보였고, 반복 작업이 빠르게 진행됨
- MVP 패턴 중심 구조는 UI 로직과 맵 데이터를 깔끔하게 분리하며, 맵 포맷이 아직 안정적이지 않아 양쪽이 자주 바뀌는 상황에서 중요함
- 에디터의 모든 부분이 Python으로 작성된 것은 아니며, model의 많은 부분은
pybast라이브러리에 의존함 pybast는 pybind를 통한 엔진 내부 Python 바인딩이며, game data archive 읽기, game textures 읽기, entity coordinates용 fixed point class, serialization을 제공함- 이미 C++로 구현된 기능을 Python에서 다시 구현하지 않기 위한 선택이며, 엔진과 도구가 작고 밀접한 생태계를 이룸
출시 계획과 공개 방식
- Catlantean 3D는 2027년 1분기 중 공개를 예상함
- 현재 초점은 레벨 디자인, 적과 무기 추가, 진행 중인 polish 작업임
- 가격 목표는 5~8달러 범위임
- 게임 소스 코드는 GitHub에 오픈소스로 공개할 계획임
- 그래픽, 레벨, 사운드, 음악 등이 들어 있는 실제 data archive는 게임을 구매해야 받을 수 있음
- 과정의 투명성은 지속적인 신뢰를 만드는 몇 안 되는 요소로 여겨짐
- 인디 게임은 AAA와 달리 더 작은 관객에게 의존하지만, 관객은 프로젝트를 따라가고 응원하고 다른 사람에게 알릴 의향이 더 큼
- 작업 과정을 보여주는 일은 만들고 있는 것에 실제로 신경 쓰고 있음을 드러내는 가장 정직한 방식으로 제시됨