← 목록으로
지난 소식
AI 시대의 커리어 조언 - 문제를 푸는 능력보다 찾고 선택하는 능력
요약
- SQLDoom은 1993년 원작 게임 '둠(Doom)'의 게임 로직과 렌더러를 SQL과 CedarDB 데이터베이스만을 사용해 완벽히 재현한 프로젝트입니다.
- 조건부 UPDATE와 테이블 조인 등을 활용해 게임 루프, 몬스터 AI, 프레임버퍼 렌더링을 구현했으며 최대 4인 데스매치까지 지원합니다.
- SQLDoom은 1993년 Doom의 게임 로직과 렌더러를 SQL로 옮겨 CedarDB 안에서 실행함. Python 클라이언트는 입력, 타이밍과 완성된 화면 표시만 담당함
- 게임 로직은 원작과 같은 35Hz로 실행하고, 320×200 화면을 노트북에서 보통 60FPS, 복잡한 장면에서는 35FPS로 생성함
- 몬스터를 하나씩 순회하는 대신 조건부 UPDATE와 테이블 조인으로 게임 로직을 처리함. 같은 기능을 원작 C 코드 약 9,000줄보다 적은 SQL 약 5,900줄로 구현함
- 무기 속성과 애니메이션도 테이블에 저장해 실행 중 수정할 수 있음. 샷건이 한 번에 산탄 500개를 발사하도록 바꾸는 시연도 포함
- 데이터베이스의 트랜잭션과 접근 제어로 최대 4인 데스매치를 지원함. 공개 서버에서 플레이하거나 SQL 콘솔로 진행 중인 경기 상태를 조회할 수 있음
DOOMQL에서 실제 Doom으로
- 전작 DOOMQL(GitHub)은 Doom과 비슷한 ASCII 화면을 30FPS로 렌더링했지만, 레이캐스팅을 사용해 실제로는 Wolfenstein 3D에 더 가까웠음
- Doom은 BSP 트리로 깊이 순서를 적은 비용으로 계산해 텍스처, 임의 각도의 벽, 서로 다른 바닥 높이를 구현함
- SQLDoom의 목표는 외형뿐 아니라 원작의 플레이 감각까지 재현하는 것임
- 렌더러는 SQL만 사용하며, 모든 픽셀의 정확한 RGB 값을 담은 테이블이나 비트맵만 출력함
- 게임 루프도 SQL 기반으로 구현하되, 데이터베이스 내부 사용자 정의 함수는 허용함
- 다른 언어로 만든 클라이언트는 입력 해석, 게임 틱 구동, 출력 비트맵 표시만 담당함
게임 상태와 렌더링을 분리한 구조
- 단일 Python 스크립트가 pygame 으로 입력과 화면 표시를 처리하고 초당 35회 게임 틱을 요청함. 게임 로직, 상태, 렌더러는 모두 데이터베이스 내부에 있음
- 게임 로직과 렌더링 경로를 분리해 로직은 고정 35Hz로 실행하고, 클라이언트는 원하는 시점에 새 프레임을 요청함
- 렌더러는 게임 상태 테이블을 입력으로 받는 순수 함수로 구성함
- 틱 사이의 카메라 위치를 보간해 렌더링 주기를 게임 로직 주기와 분리함
- 원작은 틱마다 정확히 한 프레임을 그려 35FPS로 제한됐음. SQLDoom은 원작 상수를 유지하기 위해 틱당 28.6ms 예산을 지키면서, 부드러운 화면을 위해 최소 35FPS 렌더링을 목표로 함
WAD를 관계형 데이터로 가져오기
- Doom의 WAD 파일 형식은 관계형 구조로 옮기기 쉬움
- 두 VERTEXES를 LINEDEF가 연결하고, LINEDEF에는 두 SIDEDEF가 있음
- SIDEDEF는 SECTOR의 경계를 이루며, 그 안에 THINGS가 존재할 수 있음
- WAD 전체를 데이터베이스로 변환하는 데 약 1,000줄의 Python을 사용했으며, 노트북에서 Doom 1 전체를 가져오는 데 약 18초가 걸림
- 가져온 데이터는 일반 SQL로 조회할 수 있음. E1M1의 벽과 꼭짓점을 조인하고 generate_series() 로 선분을 나눠, 위에서 내려다본 지도를 ASCII로 그리는 쿼리도 가능함
SQL로 구현한 게임 틱과 몬스터 AI
- 게임 루프는 이동, 회전, 발사 입력뿐 아니라 적의 이동과 공격, 아이템 획득, 투사체와 폭발 반경을 처리함. 화면에는 적 스프라이트, 애니메이션, 시점 흔들림, HUD도 반영함
- 절차적인 틱 실행 순서는 PL/pgSQL과 유사한 CedarDB 스크립트 언어 cedarscript 로 구성함
- 시계 갱신과 실행 계획 계산 뒤 사용 입력, 특수 동작과 문, 이동, 사망을 처리함
- 상태가 바뀌면 다시 계획을 계산하고 비밀 구역, 통과 시 발동하는 선, 아이템 획득, 무기 상태, 즉시 명중 판정과 피해를 처리함
- 필요한 사운드를 재생하며, 몬스터, 구역 효과, 오브젝트 물리는 매 틱 처리함
- Python 드라이버는 SELECT doom_run_game_tic(...) 을 1/35초마다 호출하고, 내부 함수들은 SQL 문 묶음을 실행함
- 몬스터 AI는 재귀 쿼리로 시야를 판정하고, 생존 여부, 시야각, 거리, 공격 대기시간, 애니메이션 진행 상태에 따라 상태 머신을 갱신함
- 대기 상태에서 플레이어를 발견하면 추적하고, 공격 거리와 대기시간 조건을 만족하면 공격함
- 체력이 최대 체력의 음수보다 더 낮고 해당 프레임이 있으면 xdeath로 전환해 과도한 피해를 입은 적의 폭발 사망을 구현함
틱 성능과 ECS의 관계형 구현
- 확인한 가장 느린 틱은 E4M1에서 활성 몬스터 46마리가 열리는 문으로 몰려오는 상황으로, 10.45ms가 걸려 틱 예산의 약 37%를 사용함
- 활성 몬스터가 6마리인 일반적인 틱은 평균 2.15ms, 예산의 약 8%를 사용함
- 게임 로직은 SQL 약 5,900줄로 구현했으며, 같은 기능을 수행하는 원작 C 코드는 약 9,000줄임
- 적을 하나씩 순회하는 대신 UPDATE ... WHERE condition 으로 대상 집합을 지정하고, 데이터베이스가 적용 방법과 병렬 처리를 결정하게 함
- 엔티티 컴포넌트 시스템(ECS)을 관계형 구조에 대응시킬 수 있음
- 플레이어, 몬스터, 사물은 엔티티이며 위치, 스프라이트, 능력치 같은 컴포넌트를 가짐
- 몬스터 AI, 플레이어 이동, 피해 계산 같은 시스템은 특정 속성을 가진 엔티티 사이의 상호작용을 처리함
- 각 컴포넌트는 테이블이 되고, 각 시스템은 엔티티를 키로 필요한 테이블을 조인하는 UPDATE나 INSERT 가 됨
- ECS가 중시하는 데이터 지역성과 특정 컴포넌트를 가진 엔티티 순회는 SQL의 데이터 집약적 처리와 맞닿아 있음
프레임 하나를 만드는 거대한 SQL 뷰
- 렌더러는 레벨 기하 정보, 게임 상태, 플레이어 위치를 받아 완전한 프레임버퍼를 반환하는 하나의 큰 뷰임
- BSP 탐색과 보이지 않는 구역 제거 뒤 벽을 화면 공간에 투영함
- 벽 픽셀, 바닥과 천장, 스프라이트의 후보 픽셀을 생성함
- 가장 가까운 픽셀을 선택하고 COLORMAP, 상태 표시줄을 적용해 프레임버퍼를 완성함
- 구현은 주석을 제외한 SQL 약 1,300줄, CTE 89개로 구성됨. linux_doom 원본의 렌더러는 약 3,300줄로, SQLDoom의 약 2.5배임
- 최종 프레임은 64,000개의 (x, y, rgb) 행이며, string_agg로 화면 순서에 맞춰 합치면 단일 행의 192,000바이트 RGB 데이터가 됨
BSP 탐색과 가시성 제거
- 원작 Doom은 하드웨어 가속 Z 버퍼링 없이 가림 관계를 처리하기 위해 앞에서 뒤로 그리는 순서를 사용함. 가까운 벽이 이미 채운 화면 영역 뒤의 물체는 그릴 필요가 없음
- doom.wad에 미리 계산된 BSP 트리가 들어 있음
- 각 노드의 선이 지도를 둘로 나누고, 구역은 더 작은 하위 구역으로 분할됨
- 각 하위 구역은 리프이며, 내부 어디서든 모든 벽을 볼 수 있는 볼록한 형태임
- 각 노드에서 카메라와 같은 쪽의 하위 트리는 반대쪽 하위 트리보다 앞에 있음
- SQLDoom은 로드 시 모든 루트-하위 구역 경로를 미리 계산해 정렬 키로 사용함
- 현재 시점에서 경로의 앞쪽 선택은 0, 뒤쪽 선택은 1로 인코딩함
- 이를 bigint에 넣고 SUM()과 ORDER BY로 정렬해 재귀 하강을 대체함
- 40비트를 사용하며, 원작에서 가장 깊은 E4M8의 BSP 트리는 32단계임. 이 방식의 적용 범위는 원작 최대 맵의 256배보다 크지 않은 맵까지로 잡음
- 각 노드에 저장된 자식들의 경계 상자가 시야 절두체 밖에 있으면 해당 하위 트리를 제외함
- BOOL_AND(vc.keep)로 조상 중 하나라도 가시성 조건을 통과하지 못한 하위 구역을 제거함
- 이후 파이프라인은 bsp_seq와 조인해 보이는 하위 구역만 올바른 순서로 처리함
벽, 바닥, 천장을 SQL로 그리기
- Doom은 수직 벽과 지면에 평행한 천장을 사용하는 2.5D 게임이어서 완전한 3D 엔진보다 렌더링을 단순화할 수 있음
- 벽을 앞에서 뒤로 그린 뒤 남은 영역을 바닥이나 천장으로 채움
- 몬스터, 통, 아이템은 항상 플레이어를 향하는 평면 스프라이트이며, 벽과 겹치는 경우에는 가림 처리가 필요함
- 벽은 연속된 화면 열과 각 열의 연속된 픽셀 구간으로 표현되므로, generate_series() 로 열과 행을 확장해 픽셀 후보를 생성함
- 원작은 화면 열을 순회하는 R_RenderSegLoop와 픽셀을 그리는 R_DrawColumn의 두 루프를 사용함
- SQLDoom의 벽 렌더링은 평균 1.7ms가 걸림
- 원작의 바닥과 천장 렌더링인 visplane은 SQL로 직접 옮기기 어려운 명령형 알고리듬임
- ceilingclip과 floorclip 배열에 각 화면 열에서 아직 채우지 않은 구간을 저장함
- 벽을 그릴 때마다 반드시 정해진 순서로 배열을 변경하며 화면을 채움
- 사실상 영역 채우기 알고리듬이지만, 원작 C 구현에서는 적은 비용으로 3D처럼 보이는 결과를 얻음
- SQLDoom은 반복과 가변 상태 대신 패널 정렬과 윈도 함수를 사용함
- 패널은 화면의 한 열에 나타나는 벽의 일부임
- solid, 문 위의 upper, 창문이나 난간 아래의 lower처럼 직접 그리는 패널과, 발코니 아래에서 문밖으로 나올 때처럼 다른 패널의 렌더링 경계만 결정하는 패널이 있음
- 각 열의 패널을 가까운 순서로 정렬하고, ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING 범위의 MAX와 MIN으로 앞선 패널들이 남긴 구간을 계산함
- 이전 패널이 끝나는 지점과 다음 패널이 시작하는 지점 사이를 바닥과 천장으로 채움
- 바닥, 천장, 하늘 렌더링은 보통 약 3ms가 걸림
원작과 달리 필요한 픽셀별 깊이 판정
- 벽, visplane, 스프라이트 단계는 실제 화면을 바로 그리지 않고 (x, y, depth, colour) 후보를 생성함
- 원작의 고정소수점 연산을 구현하지 않아 벽, 바닥, 하늘 후보가 같은 위치에서 겹칠 수 있음
- 벽에 일부 가려진 스프라이트도 처리해야 함
- 원작처럼 정교한 순서 제어로 Z 버퍼링을 피하는 방식을 SQL로 재현하지 못해, 모든 후보를 만든 뒤 최종 픽셀을 고르는 방식을 사용함
- 후보의 깊이, 표면 우선순위, 출처 우선순위, 안정적인 동률 판정 값, 조명과 팔레트 정보를 하나의 bigint 키에 묶음
- 깊이가 상위 비트에 들어가므로 픽셀별 MIN(winner_key)으로 가장 가까운 후보를 고름
- 표면 우선순위는 벽, 스프라이트, 평면 순이며, 색상 정보도 키에 포함해 추가 조인을 피함
- 이 최적화에도 깊이 판정은 평균 8.2ms로 프레임 전체의 3분의 1 이상을 차지하며, 가장 비싼 단계임
- Ryzen 7 PRO 7840U 노트북에서는 보통 약 60FPS, 매우 복잡한 장면에서는 35FPS를 기록함
- 큰 비용은 반복 알고리듬을 흉내 내는 visplane 처리, 원작이 피했던 깊이 판정, 색상 맵 조회와 프레임버퍼 패킹 같은 픽셀별 작업에서 발생함
데이터베이스가 실제로 유용한 부분: 데이터와 모딩
- 데이터베이스 안에서 Doom을 렌더링하는 것 자체는 좋은 선택이 아니지만, 게임 속성의 관계형 저장은 내용을 살펴보고 바꾸기 쉽게 함
- 샷건은 weapon_defs의 한 행으로 표현하며, 탄약 종류, 발사당 소모량, 산탄 수, 피해, 사거리 등을 조회할 수 있음
- 예시 샷건은 탄약 1개를 쓰고 산탄 7개를 발사하며, 각 산탄의 피해는 3d5, 최대 사거리는 2048임
- weapon_frames에는 대기, 발사, 섬광 상태의 프레임, 지속 틱 수, 공격 프레임 여부, 재발사 확인 여부를 저장함
- 속성과 애니메이션이 데이터이므로 실행 중 모딩도 가능함. 시연에서는 샷건을 한 번에 산탄 500개를 더 넓게 발사하도록 변경함
- JSON 파일에도 같은 데이터를 저장할 수 있지만, 이 경우 수정 시점의 제약조건 검증이 없고 변경 사항을 적용하려면 다시 로드해야 함
트랜잭션과 권한으로 구성한 4인 멀티플레이
- 데이터베이스는 인증, 동시성 제어, 접근 제어, 일관된 상태 스냅샷, 바이너리 통신 프로토콜을 제공해 멀티플레이 서버 구현 부담을 줄임
- 별도 Python referee 스크립트가 공유 35Hz 시계와 맵 전환을 구동하고, 각 플레이어 클라이언트는 입력만 전달함
- 게임 틱을 하나의 트랜잭션으로 실행하면 최대 4명의 플레이어가 틱 시작 전이나 완전히 커밋된 후의 일관된 상태를 봄
- 부분적으로 적용된 갱신이나 그로 인한 물리 오류, 로켓 명중 여부의 불일치를 피할 수 있음
- 내부에는 약 110개 테이블과 100개가 조금 넘는 함수가 있지만, 네 플레이어 역할은 소수의 명확한 API 함수만 호출할 수 있음
- 나머지 객체에 대한 접근 권한은 회수함
- api_input은 SECURITY DEFINER 권한으로 테이블을 변경하지만, 플레이어에게는 함수 호출 권한만 부여함
- session_user로 플레이어를 식별하며, 전후 이동, 좌우 이동, 달리기, 회전, 발사, 무기 선택, 버튼이나 문 사용 입력만 받음
- 입력값은 허용 범위로 제한하므로 클라이언트가 보낸 값을 그대로 신뢰하지 않음
- 클라이언트당 코어 3개로 안정적인 35FPS를 얻고 게임 틱도 예산 이내를 유지함. 틱 드라이버용 코어 하나를 더하면 16코어 머신으로 원작의 -altdeath 데스매치를 운영할 수 있음
- 공개 인스턴스는 에피소드 1 맵을 10분마다 전환하며, 선착순 4명까지 플레이할 수 있음
- 자리가 차면 대기열에 들어가고, 대기열까지 가득 차도 SQL 콘솔로 실시간 경기 상태를 조회할 수 있음
- EU 서버와 US 서버에서 셰어웨어 첫 번째 에피소드를 제공함
SQL 컴파일 결과와 C 코드 비교
- CedarDB는 복잡한 쿼리를 여러 단계를 거쳐 LLVM IR로 변환한 뒤 기계어로 컴파일하는 데이터베이스임
- 오브젝트의 이동과 운동량 처리 일부를 비교하면, 원작 C는 48개 명령어, SQLDoom은 117개 명령어로 컴파일됨
- 로직 배치가 달라 완전히 일대일로 대응하는 비교는 아님
- 추가 명령어 중 42개는 C 코드에는 필요 없는 테이블 결과 저장에 사용됨
- SQL이 쿼리 최적화기를 거쳐 LLVM까지 내려오는 추상화 계층을 고려하면 차이가 예상보다 작았음
존 카맥의 설계는 SQL에서도 통함
- DOOMQL과 SQLDoom은 같은 엔진에서 SQL을 입력받아 비트맵을 출력하는 동일한 제약을 따름
- DOOMQL의 단순 레이캐스팅은 SQL로 표현하기 쉽고 단계 간 의존성이 적어 집합 기반 처리에 더 잘 맞음
- 그런데 SQLDoom의 BSP 기반 접근은 더 빠르면서 시각적 충실도도 높음. 원작이 486 하드웨어에서 최대한의 결과를 끌어내기 위해 사용한 설계가 SQL에서도 효과를 냄
- 다만 DOOMQL 개발 당시보다 CedarDB 엔진도 빨라졌으며, 당시에는 없던 역할 기반 접근 제어도 추가됐음
직접 실행하기
- 소스는 SQLDoom GitHub 저장소에 공개돼 있으며, 실행 절차는 README를 따르면 됨
- 필요한 구성 요소는 CedarDB Community Edition, psycopg2와 pygame을 사용하는 Python, Doom IWAD임
- WAD는 별도로 준비해야 함
- 자유롭게 재배포할 수 있는 셰어웨어 doom1.wad 로 에피소드 1을 플레이할 수 있으며, apt install doom-wad-shareware로 구할 수 있음
- 정식판 WAD도 소유하고 있다면 사용할 수 있음
키워드
SQLDoomCedarDB둠(Doom)