1993년처럼 그래픽 만들기

1 hour ago 2
  • 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와 달리 더 작은 관객에게 의존하지만, 관객은 프로젝트를 따라가고 응원하고 다른 사람에게 알릴 의향이 더 큼
  • 작업 과정을 보여주는 일은 만들고 있는 것에 실제로 신경 쓰고 있음을 드러내는 가장 정직한 방식으로 제시됨
Read Entire Article