0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SQLだけで動くFPS「DOOMQL」は本当に遊べる速度が出るのか試してみた

0
Posted at

はじめに

今年の7月ごろ、Simon Willison氏のブログで紹介されて話題になっていた DOOMQL というプロジェクトがあります。SQLiteをゲームエンジンとして使ったDoom風のFPSで、READMEの一文が印象的です。

A playable first-person shooter whose framebuffer is a SQL query.

(出典: petergpt/doomql README)

プレイヤーの移動も敵の動きも、画面の1ピクセルごとの色まで、すべてSQLで計算しているそうです。SQLだけでFPSが動くというのが面白くて、試しに自分でも動かしてみました。SQLでどうやって3Dっぽい画面を作っているのか、ゲームとして遊べる速度が出るのかも気になったので、あわせて調べています。

この記事では、手元のMacでDOOMQLを動かす手順から、SQLが画面を作る仕組みまでを順に見ていきます。SQLは普段書くけれど、ゲームの描画の仕組みはよく知らない、という人を想定しています。検証したのは2026年7月時点のリポジトリ(commit a2b40b6)です。

動かしてみる

必要な環境

READMEに書かれている動作条件は次のとおりです。

  • macOS、Linux など(WindowsはWSL)
  • Python 3.11以上
  • SQLite 3.45以上(数学関数が有効なもの)
  • 24ビットカラーと ▀ の文字を表示できるターミナル
  • ターミナルの大きさは80列×33行以上を推奨

外部のPythonパッケージは使っていません。今回使ったのは次の環境です。

  • macOS 26.5.1(Apple M5)
  • Python 3.14.6(Homebrew)
  • SQLite 3.53.3(Python に組み込まれているもの)

PythonとSQLiteのバージョンは、次のコマンドで確認できます。

python3 -c "import sys, sqlite3; print(sys.version.split()[0], sqlite3.sqlite_version)"

cloneして起動する

リポジトリをcloneして、この記事で検証したcommitに切り替えます。

git clone https://github.com/petergpt/doomql.git
cd doomql
git checkout a2b40b6

あとは make run で起動します。

make run

80列×33行のターミナルで起動すると、次のような画面になります。

make run で起動したDOOMQLの画面

画面の下の3行はHUDで、体力(HP)、弾数(AMMO)、スコア、ゲームの経過時間(TICK)、目的のメッセージ、操作方法が表示されています。この画面もすべてSQLで作られています。

操作は次のとおりです(READMEより)。

キー 操作
W / S 前進 / 後退
A / D 左右に平行移動
J / L または矢印キー 左右に旋回
スペース 発砲
E ゲートを開ける
P 一時停止
Ctrl-C 終了

ゲームの状態は .doomql/doomql.sqlite というSQLiteのファイルに保存されます。

SQLiteが古い環境で動かすには

実は最初は、Ubuntu 22.04系のLinuxコンテナで試していました。このコンテナのSQLiteは3.37.2、Pythonは3.10で、どちらもREADMEの動作条件を満たしていませんでした。起動すると、次のエラーが出ます。

RuntimeError: SQLite 3.45+ is required; this connection uses 3.37.2

DOOMQLは起動時にSQLiteのバージョンを確認していて、必要なバージョンがエラーメッセージで分かるようになっています。このコンテナでは apt でSQLiteを更新できなかったので、新しいSQLiteを同梱したPythonパッケージ sqlean.py を pip install sqlean.py で入れ、標準の sqlite3 モジュールと差し替えました。

import sys, sqlean
sys.modules["sqlite3"] = sqlean  # 以降の import sqlite3 はsqleanになる

この差し替えをしたうえで host/doomql.py を読み込むと、DOOMQL側のコードは変更せずに動きました。システムのSQLiteを更新できない環境では、この方法が使えます。

リポジトリの中身

ファイルの構成は次のようになっています(行数は手元で数えたものです)。

ファイル 行数 中身
sql/001_schema.sql 266 テーブル定義(プレイヤー、敵、マップ、入力など)
sql/002_level.sql 181 マップのデータ
sql/003_render.sql 525 画面を作るSQL
sql/tick.sql 897 入力、移動、敵の動き、戦闘などのゲームの処理
host/doomql.py 582 Python側。キー入力をSQLiteに渡し、結果を表示する
tools/ 遊んでいる最中のSQLを確認するツールなど
tests/ テスト
docs/SPEC.md 設計の説明

SQLは全体で約2,000行です。READMEには、SQLとPythonの役割分担が表で整理されています。要約すると次のとおりです。

SQLが担当すること Pythonが担当すること
キー入力の解釈、移動、当たり判定 キーが押されたときの生のバイト列を渡す
敵の動き、戦闘、ゲームの進行 現在時刻を渡す
画面の全ピクセルの色の計算 ターミナルの大きさを渡す
色を付けるための制御文字の生成 SQLが返した文字列をそのまま表示する

Pythonのコードには、キーの割り当てやゲームの数値は書かれていません。ゲームの1コマ分の時間(DOOMQLではtickと呼んでいます)が進むたびに、Pythonは入力をテーブルに書き込み、tick.sql のSQLを実行します。そのあと、次のクエリで画面の行を受け取って表示します。

SELECT scanline FROM render_frame ORDER BY y;

render_frame は 003_render.sql で定義されているビューで、1行がターミナルの1行分の文字列です。

速度を測る

DOOMQLには、画面を表示せずに処理時間だけを測るベンチマークのモードがあります。make benchmark で実行できます。

$ make benchmark
python3 host/doomql.py --reset --benchmark 20
frames=20
tick_ms=1.335
render_ms=22.791
total_fps=41.45
average_frame_bytes=24632

それぞれの意味は次のとおりです。

  • tick_ms: ゲームの処理(tick.sql)1回にかかった時間
  • render_ms: 画面を作るクエリ1回にかかった時間
  • total_fps: 1秒あたりに処理できるフレーム数
  • average_frame_bytes: 1フレーム分の文字列の平均サイズ

make benchmark は20フレーム分を測ります。--reset が付いているので、実行するとゲームの状態は最初に戻ります。フレーム数を増やしたいときは、直接 host/doomql.py を実行します。

$ python3 host/doomql.py --benchmark 100
frames=100
tick_ms=0.874
render_ms=20.326
total_fps=47.17
average_frame_bytes=20689

どちらもおよそ40〜50fpsでした。画面を作るクエリが1回20ms前後なので、遊ぶには十分な速度です。

画面を大きくすると遅くなる

7月のLinuxコンテナ(4コア、GPUなし)では、ターミナルの大きさを変えて測りました。ターミナルの大きさは terminal_surface テーブルを UPDATE すると変えられます。各30フレームの平均です。

ターミナル ピクセル数 tick render fps
80×33 4,800 1.2ms 19.4ms 48.6
120×45 10,080 1.3ms 51.0ms 19.1
160×54 16,320 1.7ms 114.5ms 8.6
240×80 19,200 1.7ms 106.4ms 9.3

画面を作る時間は、ほぼピクセル数に比例して伸びました。160×54まで広げると8.6fpsまで下がるので、大きな画面で遊ぶのは難しそうです。240×80を指定しても19,200ピクセルで止まったので settings テーブルを見ると、内部の解像度は160×120が上限になっていました。READMEにも、大きくしたいときはターミナルのフォントを大きくするように書かれています。

SQLが画面を作る仕組み

ここからは sql/003_render.sql の中身を見ていきます。その前に、出てくる用語を2つ説明します。

CTEと再帰CTE

CTE(共通テーブル式)は、WITH 句でクエリの途中結果に名前を付ける書き方です。WITH RECURSIVE を使うと、直前に作った行をもとに次の行を作る処理を繰り返せます。これを再帰CTEと呼びます。たとえば、1から5までの数を作るクエリは次のようになります。

WITH RECURSIVE nums(n) AS (
    SELECT 1
    UNION ALL
    SELECT n + 1 FROM nums WHERE n < 5
)
SELECT n FROM nums;  -- 1, 2, 3, 4, 5

最初の SELECT 1 で1行目を作り、UNION ALL の後ろのSELECTで、前の行の n に1を足した行を作ります。WHERE n < 5 を満たさなくなったところで止まります。DOOMQLは、この繰り返しを使ってゲームの画面を作っています。

レイキャスティング

DOOMQLの画面は、レイキャスティングという方法で描かれています。昔の3Dゲームでよく使われていた方法で、次の手順で壁を描きます。

  1. 画面の横1列ごとに、プレイヤーの位置から視線(レイ)を1本ずつ飛ばす
  2. マップのマス目を1つずつ進み、壁のあるマスに当たったら止まる
  3. 壁までの距離が近いほど、その列に壁を縦長に描く

2のマス目を1つずつ進む方法は、DDA法と呼ばれます。

003_render.sqlの流れ

003_render.sql の frame_pixels ビューは、1ピクセルを1行として、座標と色(x, y, r, g, b)を返します。このビューは複数のCTEをつないで作られていて、レイキャスティングの部分は次のようになっています。

まず rays で、画面の列ごとに視線の向きを計算します。列の番号は、0から順に数が入った numbers テーブルから取り出しています。

rays AS MATERIALIZED (
    SELECT
        n.n AS x,
        2.0 * (n.n + 0.5) / c.width - 1.0 AS camera_x,
        c.dir_x + c.plane_x * (2.0 * (n.n + 0.5) / c.width - 1.0) AS ray_x,
        c.dir_y + c.plane_y * (2.0 * (n.n + 0.5) / c.width - 1.0) AS ray_y
    FROM numbers AS n
    JOIN cfg AS c
    WHERE n.n < c.width
),

(出典: petergpt/doomql sql/003_render.sql 35〜44行目)

次の ray_start で、DDA法の初期値(どちらの向きに1マス進むか、次のマスの境界までの距離など)を計算します。そのあと walk という再帰CTEで、マス目を1つずつ進めます。walk は、上のCTEの例と同じく、前の行をもとに次のマスの行を作り、壁のあるマスに当たったところで止まります。列ごとに壁までの距離が分かったら、そこから壁の高さを決めて、ピクセルの色を計算しています。

色をターミナルの文字にする

frame_pixels で計算したピクセルは、render_frame ビューでターミナルに表示する文字列に変わります。DOOMQLは、上半分だけを塗る ▀ という文字を使い、文字の色で上のピクセル、背景色で下のピクセルを表しています。ターミナルの1文字で縦2ピクセル分を表示できるので、80列×33行のターミナルなら、HUD(体力や弾数の表示)の3行を除いた30行で、80×60ピクセルの画面になります。

色の指定には、ANSIエスケープシーケンスと呼ばれる制御文字を使います。隣のピクセルと同じ色なら色の指定を省けるので、この判定にウィンドウ関数の lag() を使っています。lag() は、並び順で1つ前の行の値を取り出す関数です。

lag(fg) OVER (PARTITION BY terminal_y ORDER BY x) AS previous_fg,
lag(bg) OVER (PARTITION BY terminal_y ORDER BY x) AS previous_bg

(出典: petergpt/doomql sql/003_render.sql 457〜458行目)

PARTITION BY terminal_y ORDER BY x は、ターミナルの行ごとに、左から順に並べるという意味です。小さな例で試すと、次のようになります。

WITH t(x, c) AS (VALUES (1, 'red'), (2, 'red'), (3, 'blue'))
SELECT x, c, lag(c) OVER (ORDER BY x) FROM t;
-- (1, 'red', NULL), (2, 'red', 'red'), (3, 'blue', 'red')

2行目は前の行と同じ red なので、色の指定を省けます。最後に、1行分の文字を group_concat(token, '' ORDER BY x) で左から順につなげて、ターミナルの1行分の文字列にしています。集約関数の中に ORDER BY を書く構文はSQLite 3.44で追加されたもので、DOOMQLがSQLite 3.45以上を必要とする理由の一つです。

遊びながらSQLを見る

DOOMQLには、遊んでいる最中にどのSQLが実行されたかを確認するツールが付いています。ターミナルを2つ開き、1つ目で make run、2つ目で make inspect を実行します。

# 1つ目のターミナル
make run

# 2つ目のターミナル
make inspect

make inspect はゲームと同じデータベースを読み取り専用で開き(PRAGMA query_only=ON)、直近で状態が変わった出来事と、その変化を起こしたSQLを表示します。

プレイヤーを前進させたときの表示が次の画面です。

前進したときの make inspect の表示

上から順に、次の内容が表示されています。

  • 1〜3行目: 画面に表示中のフレームのtick、プレイヤーの座標や体力、画面を作ったクエリ(SELECT scanline FROM render_frame ORDER BY y;)
  • SQL EVENT: MOTION: プレイヤーの位置が変わったという出来事。BEFOREとAFTERで、x座標が4.9301から5.0664に増えています
  • EXACT SQL RESPONSIBLE: その変化を起こした tick.sql の該当部分。行番号付きで表示され、j / k でスクロールできます

何も操作せずに放置していたら、敵に倒されてしまいました。そのときは次のように表示されました。

倒されたときの make inspect の表示

SQL EVENT: STATE で、game_status テーブルの state が running から dead に変わったことが分かります。その下には、体力が0以下になったら state を dead にする UPDATE 文が表示されています。ゲームオーバーの処理も、ふつうのSQLの UPDATE で書かれていることが分かります。

キー入力もSQLで扱われている

Pythonはキーが押されると、そのバイト列を input_events テーブルに書き込むだけです。どのキーで何をするかは、SQL側の key_bindings テーブルで決まっています。

INSERT INTO key_bindings(raw_code, action) VALUES
    (X'77', 'forward'),       (X'57', 'forward'),
    ...
    (X'20', 'fire'),

(出典: petergpt/doomql sql/002_level.sql 77〜89行目より抜粋)

X'77' は w、X'20' はスペースのバイト列です。ということは、キーボードを使わなくても、input_events テーブルに行を入れればゲームを操作できるはずです。make run でゲームを動かしたまま、別のターミナルから次のPythonスクリプトを実行してみました。

import sqlite3, time

c = sqlite3.connect(".doomql/doomql.sqlite", timeout=5, isolation_level=None)

def key(code):
    # 現在のゲーム内時刻で、キー入力を1行追加する
    c.execute(
        "INSERT INTO input_events(captured_us, raw_code) "
        "SELECT sample_us, ? FROM game_clock WHERE id = 1",
        (code,),
    )

q = "SELECT round(x, 2), round(y, 2), health, ammo FROM player"
print("before :", c.execute(q).fetchone())
key(b"r"); time.sleep(0.4)      # 倒された状態からやり直す
print("restart:", c.execute(q).fetchone())
for _ in range(8):
    key(b"w"); time.sleep(0.12)  # 前進
print("forward:", c.execute(q).fetchone())
key(b" "); time.sleep(0.3)      # 発砲
print("fire   :", c.execute(q).fetchone())

結果は次のとおりです。

before : (2.5, 3.5, 0, 32)
restart: (2.5, 3.5, 100, 32)
forward: (4.67, 3.5, 100, 32)
fire   : (5.07, 3.5, 100, 31)

r でやり直して体力が100に戻り、w でx座標が増え、スペースで弾数が32から31に減りました。ゲームの画面も同じように動いています。上の make inspect の画面は、このとき前進した様子を表示したものです。入力もテーブルの1行なので、SQLのINSERTだけでゲームを操作できました。

試して分かったこと

  • macOSでPython 3.11以上とSQLite 3.45以上がそろっていれば、clone して make run だけで遊べます。SQLiteが古い環境では、sqlean.pyで sqlite3 モジュールを差し替えて動かせました
  • 80×60ピクセルの画面なら、手元のMacで約40〜50fps出ました。SQLのビューで画面を作るという重そうな構成でも、この大きさなら遊べる速度になるのが意外でした
  • 画面を作る時間はピクセル数にほぼ比例するので、遊ぶなら小さめのターミナルか大きめのフォントが良さそうです
  • ゲームの状態も入力もすべてテーブルにあるので、SELECTで中身を確認したり、INSERTで操作したりできます。make inspect で、状態を変えたSQLをその場で確認できるのも面白いところです

再帰CTEやウィンドウ関数を実際に使っている例としてもよくできているので、SQLの勉強の題材としても面白いと思います。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?