将初代 Doom 游戏移植到 SQL
TL;DR:我们把 1993 年初代 Doom 的游戏逻辑和渲染器移植到了 SQL 里,直接在数据库中运行。游戏循环保持原版的 35 FPS,渲染器在我的笔记本上能以最高 60 Hz 的频率输出完整的 320x200 帧缓冲。Python 只负责计时、读键盘,以及把数据库返回的位图显示出来。多人模式也能用。
Your browser does not support the video tag.SQLDoom 在 AMD Ryzen 7 7840U 上的实机运行画面现在就能玩 死亡竞赛模式,四个位置,先到先得。
这是第一关的共享软件版本。如果位置满了,你会进入排队队列。就算在排队,你也可以通过 SQL 随便查询实时的游戏状态来打发时间。
SQLDoom
去年我发布了 DOOMQL [Github]。它以 30 FPS 渲染出大致像 Doom 的 ASCII 画,很受欢迎。但有人正确地指出,它其实更接近 Wolfenstein 3D 而非 Doom,因为它用的是光线投射方法。而 Doom 使用的是 BSP 树,让正确的深度排序变得足够便宜,因此才能支持贴图、任意角度的墙壁和高度不一的地板。
这事我一直耿耿于怀,折腾了一阵之后(没错,又是休育儿假),我终于可以展示完全用 SQL 运行的真 Doom 了。

规则
先明确几条我们想达成的基本目标:
- 画面要像原版 Doom。现在回头看,DOOMQL 的画面表现实在有点拿不出手。
- 但更重要的是,玩起来也要有原版的感觉。原版游戏的精髓就是一个字:爽。
- 渲染必须完全基于 SQL。SQL 的输出只能是一张表或一个位图,为每个像素编码精确的 RGB 值。
- 游戏主循环也必须完全基于 SQL 实现,但允许在数据库内部使用用户自定义函数(UDF)。
- 允许使用其他编程语言编写客户端,前提是该客户端仅负责解析输入、驱动游戏节拍(tick)以及渲染输出位图。
架构设计
Python 在规则 5 中被定位为一种“无趣”的胶水层。单个脚本借助 pygame 处理输入、绘制输出位图,并每秒触发 35 次游戏节拍。游戏逻辑、游戏状态以及渲染器均驻留在数据库内部。
Python
输入 / 时序 / 显示
| ^
| |
运行游戏节拍 请求帧数据
| |
v |
+----------------+ +----------------+
| | | |
| SQL 游戏逻辑 | | SQL 渲染器 |
| | | |
+-------+--------+ +--------+-------+
| ^
| |
v |
+-----------------------------+
| |
| 游戏状态表 |
| |
+-----------------------------+
这两条路径被刻意分离:游戏逻辑在固定的 35 Hz 循环上运行,而渲染器则是游戏状态表的纯函数。客户端可以随时请求新帧(即尽可能快速且频繁地渲染)。
加载游戏数据
Doom 的 .wad 文件格式 本身就已具有高度关系型特征,这让数据迁移变得非常便捷。
两个 VERTEXES 通过 LINEDEF 相连,后者包含两个 SIDEDEF。SIDEDEF 界定了一个 SECTOR,其中可以包含 THINGS,如此类推。将整个 WAD 文件转化为数据库的过程比想象中简单,仅需约 1000 行 Python 代码即可完成。在我的笔记本电脑上,导入整个 Doom 1 大约需要 18 秒。
例如,以下是一个从俯视视角渲染 E1M1 的查询:
WITH wall AS (
SELECT round((v1.x + (v2.x - v1.x) * t / 32.0) / 48) AS col, -- 每列 48 个单位
round((v1.y + (v2.y - v1.y) * t / 32.0) / 96) AS row, -- 字符宽高比为 2:1
l.left_sd_id < 0 AS solid -- 单侧线段可通行
FROM linedefs l, generate_series(0, 32) AS t -- 将每条线段分为 32 步遍历
JOIN vertexes v1 ON (v1.map_id, v1.id) = (l.map_id, l.v1_id)
JOIN vertexes v2 ON (v2.map_id, v2.id) = (l.map_id, l.v2_id)
WHERE l.map_id = 1
)
SELECT string_agg(CASE WHEN (col, row) IN (SELECT col, row FROM wall WHERE solid) THEN '#'
WHEN (col, row) IN (SELECT col, row FROM wall) THEN '.'
ELSE ' ' END, '' ORDER BY col)
FROM generate_series(-16, 79) AS col, generate_series(-51, -21) AS row
GROUP BY row ORDER BY row DESC;
输出结果:
#####################
# ..................#
# . ...... .#
# . ...... ###### .#
###### .. ## .#
#####.. . .. ## ##
# ####### ...... ###### ##########
# ## # . ###.. ..##
################ ## # ###.........######## #####. ##
### ........... # ########..########.........### #### .## ######
# .. ########## #### ## ## #..... ####### ##
# . ##### ... ## ### #.....#..## ........ ##########..... ....##.#### ##
# . ###.### ...... ### . . ## ... ... . ...... .# ## ##
# . ##.. . ...... ## . . ## . . . .......... ## ## ##
# . ###.###### ... ###### #.....#..## ... .. #. ... .. # ## ##
# . ############ # .. .......... #.......... ...# # #
###......... # ##### ##### ##..... ... ##.### #
################ ####### ####.........##.##........####### .####### ### #
###########.################# # #### # # ##
#.# ####...... # # . ### ##
#.################# # ###########
####. .#### #..#
##### ######..######
# . .. #
# ##...## #
# ## ## #
######..######
####
#####
#.. #
#####
游戏循环
对我而言,关键在于真正移植Doom,而不仅仅是渲染出几帧看起来差不多的画面。 当然,视觉效果至关重要,但 Doom 玩起来的那种手感同样出色。 看看下面这个场景,这是规则 2 的实际应用(玩得挺开心):

你可以看到,这里发生的事情非常多。仅仅在这段短片段里,我们可以看到:
- 需要轮询并处理玩家输入(移动、转向、射击),
- 敌人会行走并发起攻击,
- 物品会被拾取,
- 火箭筒发射弹道移动,
- 火箭爆炸具有冲击半径,
- 敌人精灵需要被渲染,
- 动画、视角晃动以及 HUD
我们没多少时间来处理这一切:
原版 Doom 运行在固定的 35 Hz 时钟上,因此一个 tic 的预算为 1000 ms × 35 Hz = 28.6 ms。
它每个 tic 恰好绘制一帧,因此帧率也被限制在 35 FPS。
SQLDoom 将游戏逻辑保持在 35 Hz(这样所有原始常数仍然有效),但将绘制逻辑解耦。 客户端可以随时查询(注意到了吗?)一帧,我们在两个 tic 之间对相机位置进行插值。 因此有两个预算需要我们关注:
- 每 28.6 ms 运行一个 tic(否则感觉会完全不对)
- 每秒渲染至少 35 帧(少一点勉强可以,但不会流畅)
Tic 序列
游戏 tic 本质上是程序化的。每次运行 tic 时,我们都要执行一系列固定的步骤。
CedarDB 有一种叫做 cedarscript 的脚本语言,它非常类似于 PL/pgSQL,允许我们预先规划每个 tic 要做什么。
这是 tic 函数的一小段:
doom_cs_clock(map, p);
let mut plan = doom_cs_plan(map, p); -- 返回需要触发的函数位掩码
let use_queued = doom_tic_use(map, p, plan);
if (plan & 2) <> 0 OR use_queued { active = doom_cs_activate_specials(map); }
if (plan & 4) <> 0 OR active <> 0 { doom_cs_doors(map, p); }
doom_tic_move(map, p); -- 完整移动,或仅转向
doom_cs_death(map, p); -- 处理死亡
plan = doom_cs_plan(map, p); -- 世界已变动;重新规划
plan = doom_tic_secrets(map, p, plan); -- 秘密、踩线、拾取物品
plan = doom_tic_weapon(map, p, plan); -- 武器状态、命中扫描、伤害
...
if sound_due { doom_cs_sound(map, p); } -- 是的,我们也播放声音
doom_cs_monsters(map, p); -- 始终执行
doom_cs_sector_fx(map, p); -- 始终执行
doom_cs_thing_physics(map); -- 始终执行
上面提到的 Python 驱动程序每 1/35 秒调用一次 SELECT doom_run_game_tic(...)。
上述被调用的每个函数随后都会执行一批 SQL 语句。 以下是怪物 AI 状态机的一部分。
-- 摘自 sql/runtime/functions/26_cs_monsters.sql。
WITH RECURSIVE
monsters AS ( [...] ), -- 谁还活着、什么类型、在哪里
los AS ( [...] ), -- 可见性、in_view_cone、距离:递归 CTE,沿墙搜索
decision AS ( [...] ), -- 每个角色一行:其状态和可见的内容
transitions AS (
SELECT d.*,
CASE
WHEN NOT d.alive AND d.state NOT IN ('die', 'dead', 'xdeath') THEN
CASE WHEN d.health < -d.max_health AND d.xdeath_frame IS NOT NULL
THEN 'xdeath'::actor_state ELSE 'die'::actor_state END -- 血腥爆炸!
WHEN d.state = 'stand' THEN
CASE WHEN d.visible AND d.in_view_cone AND d.dist <= sight_range
THEN 'see'::actor_state ELSE 'stand'::actor_state END
WHEN d.state_tics > 1 THEN d.state -- 动画还在播放
WHEN d.state = 'see' THEN
CASE WHEN d.visible AND d.dist <= d.attack_range
AND d.attack_cooldown <= 0
THEN 'missile'::actor_state ELSE 'see'::actor_state END
[...] -- die、xdeath、missile、pain、barrel:还有 5 种
ELSE d.state
END AS next_state
FROM decision d
)
UPDATE monster_ai ai
SET state = n.next_state, state_tics = n.next_tics, seq_index = n.next_seq,
fired_this_tick = n.advances AND n.lands_on_attack_frame
FROM next_values n
WHERE ai.map_id = n.map_id AND ai.thing_id = n.thing_id;
可以看到,这段 SQL 正是上面视频里行为的实现:当敌人受到巨额伤害(CASE WHEN d.health < -d.max_health AND d.xdeath_frame IS NOT NULL)时,就会猛烈爆炸(THEN 'xdeath'::actor_state)!
Tic 驱动器的性能
下面是一个游戏 tic 的瀑布图渲染:
我能找到的最慢的一个游戏 tic这确实是我能找到的最慢的 tic 了。它发生在 E4M1 关卡:46 只已苏醒的怪物正争先恐后地想从一扇正在开启的门冲向我。这一个 tic 耗时 10.45 毫秒,约占可用 tick 预算的 37%。
而一个更典型的情况是 6 只怪物苏醒,平均耗时 2.15 毫秒,约占预算的 8%。余量非常充裕!
说实话,用 SQL 表达如此复杂的游戏逻辑竟然这么容易,让我颇为意外。整个游戏逻辑只有约 5900 行 SQL。听起来不少,但完成同样功能的原版 C 源码足足有 9000 行左右!
它还会迫使你换一种思路。你不再需要逐个遍历敌人,而是写一条简单的 UPDATE ... WHERE 条件 语句,剩下的交给数据库去想办法——并行、自动!
这也让我终于彻底理解了实体组件系统(ECS)模式。在这里,每个实体(玩家、怪物、物件等)拥有多个组件(位置、贴图、状态等),而系统(怪物 AI、玩家移动、伤害计算等)负责决定具有特定属性组合的实体之间如何交互。ECS 的核心在于数据局部性,以及如何高效遍历具备特定组件组合的实体。而在 SQL 中,我们早已习惯于这种密集型数据处理:每个组件都变成一张表,每个系统则是一条 update 或 insert 语句,只需把关心的几张表以实体为连接键进行 join 即可!
渲染
每一帧渲染,本质上就是一个巨大的视图,输入关卡几何体、游戏状态和玩家位置,输出完整的 framebuffer。以下是渲染流水线的示意图:
WITH RECURSIVE
render_context AS (SELECT $1 AS map_id, $2 AS player_thing_id, $3 AS difficulty),
pos AS (SELECT $4 AS x, $5 AS y, $6 AS z, $7 AS angle),
visible_children AS ( ... ), -- 遍历 BSP,剔除不可见线段
clipped, projected, on_screen, -- 将线段投影到屏幕空间
wall_parts, columns, fragments, -- 每个墙面像素一行
panel_clips, plane_spans, ..., -- 天花板/地板作为窗口函数处理,可见平面
thing_pixels, sprite_fragments, -- 贴图精灵
fragment_union, resolved, -- 所有候选像素,选取最近的进行解析
view_colored, ui_colored, -- 色映射表,状态栏
framebuffer AS ( ... ) -- 64,000 行 (x, y, rgb)
SELECT string_agg(rgb, ''::bytea ORDER BY y, x) AS frame_rgb
FROM framebuffer; -- 192,000 字节,仅一行
整个实现约 1300 行 SQL(不含注释),分布在 89 个 CTE 中,对一条 SQL 查询而言相当复杂!

单帧渲染涉及的全部 89 个 CTE
尽管这套流程看起来极度疯狂,但它实际上非常接近 Doom 的真实做法。SQL 甚至拥有一个优势:linux_doom 源码中渲染引擎部分约有 3300 行代码(不含注释),比 SQLDoom 多出约 2.5 倍。当初这么做是否明智是另一个问题,稍后会谈到。
先来看看渲染管线中最有趣的部分:

左侧展示基于 BSP 的剔除,右侧可视化墙面渲染和可视平面(visplanes)。
BSP 遍历
1993 年还没有带硬件加速 Z-buffering 的 GPU,因此 Doom 必须通过以正确顺序绘制来处理遮挡。Doom 的方法相当巧妙:它从前向后绘制,并记录已渲染的像素(例如,如果某个墙壁像素已绘制,就不需要再绘制其后方的怪物)。但说起来容易做起来难:我们需要一种高效方式,按深度对关卡中的所有内容进行排序。
Doom 通过预计算的 BSP 树实现这种排序,这些树被固化在 doom.wad 文件中。树的每个节点是一条将地图一分为二的线。地图的各个扇区(sectors)由此被切割成大量子扇区(subsectors),位于这些线的两侧,并被插入树中,从而满足以下特性:
- 每个子扇区是一个叶节点;
- 每个子扇区是凸的(即在其内部任意位置都能看到任何一面墙);
- 在每个树节点处,位于摄像机一侧的整棵子树保证在另一侧子树的前方。
通过递归遍历 BSP 树,我们得到所有子扇区的从前到后顺序。这直接给出了渲染顺序:一旦屏幕某区域被更近的对象覆盖,其后的对象即可跳过。
这是动态效果演示(可能需要全屏观看):
您的浏览器不支持 video 标签。
BSP 遍历的可视化左侧按从前到后的顺序排列子扇区(subsector),视野外的 BSP 分支会被提前剔除。中间显示的是 SQLDoom 为每个区域分配的顺序。右侧是最终渲染的帧,墙面按其所在子扇区着色。
中间面板展示了 SQLDoom 做的一项优化:为了提升性能,我们在加载时一次性预计算好 BSP 树中的所有遍历路径。对于给定的位置,路径上的每一步要么走前侧(编码为 0),要么走后侧(编码为 1)。把这些决策打包成一个 bigint,再按字典序排序(order by),就能得到正确的前后顺序。
SELECT ssector_id, ROW_NUMBER() OVER (ORDER BY sort_key) AS bsp_seq
FROM (
SELECT st.ssector_id,
-- back = 1 at bit (40 - depth), front = 0.
SUM(CASE WHEN st.side = fs.front_side THEN 0::bigint
ELSE (1::bigint << (40 - st.depth)) END) AS sort_key,
BOOL_AND(vc.keep) AS visible -- was any parent bbox culled?
FROM node_path_steps st -- materialized view, every root-to-ssector path
JOIN nodes n ON ...
CROSS JOIN LATERAL (SELECT ... AS front_side) fs -- on which side are we?
JOIN visible_children vc ON ...
GROUP BY st.ssector_id
) s WHERE s.visible;
一个 sum() ... order by 就替代了整个递归下降过程!40 位也足以应付任何地图:最深的 BSP 树来自 E4M8,也只有 32 层。只要你的地图不超过原版最大地图的 256 倍,就完全没问题。
仔细观察的话,你会发现我们的 BSP 遍历还顺带处理了剔除:.wad 中的每个节点恰好都定义了其所有子节点的包围盒。如果我们能证明视锥体完全位于该包围盒之外,那这棵子树就不用参与渲染——这正是 visible_children.keep 的含义。于是 bool_and(vc.keep) 会过滤掉所有祖先节点不满足条件的子扇区。
流水线中后续的所有步骤都只需按 bsp_seq 进行连接,这样就能只处理可见的子扇区,并保持正确的顺序。
墙面与 Visplane
Doom 其实有点取巧,它看起来是 3D 的,但本质上是 2.5D 游戏。 它基本上就是一个平坦表面,墙面完全垂直,天花板始终与地面平行。 这让渲染比真正的 3D 引擎容易得多:
- 绘制所有墙壁(按前后顺序绘制,如前所述)。
- 尚未绘制的部分要么是地板,要么是天花板,绘制它们即可。
- 精灵(怪物、木桶、拾取物)是扁平图像,始终正对玩家(想象一下纸板立牌),因此不涉及复杂的变换(除了当它们遮挡墙壁时,但这点我们稍后再说)。
墙壁
墙壁占据一组连续的屏幕列,在每一列中它都是连续的像素跨度。
因此我们可以逐个绘制墙壁,从前到后,通过 generate_series() 扩展行和列:
columns AS ( -- emit a row per screen column the wall w covers
SELECT w.*, x AS col_x, ...
FROM wall_parts_tex w
CROSS JOIN LATERAL generate_series(
GREATEST(0, FLOOR(w.screen_x1)::int),
LEAST(screen_w - 1, CEIL(w.screen_x2)::int)) AS x
),
fragments AS ( -- one row per pixel the wall covers in this column
SELECT c.col_x AS x, y, c.depth_x AS depth, c.u_i, c.v_i
FROM clamped_spans c
CROSS JOIN LATERAL generate_series(c.y_start, c.y_end) AS y
)
Doom 使用两个循环:R_RenderSegLoop 用于获取屏幕列,R_DrawColumn 用于绘制像素。
渲染墙壁平均花费我们 1.7 毫秒。
可视平面
解决了墙壁之后,让我们谈谈有趣的部分:地板和天花板,也就是 Doom 所称的 visplanes。
Doom rendering algorithm doesn't translate to SQL well: its imperative nature is too rigid. Doom uses two arrays,ceilingclip and floorclip, each with one entry per screen column. They mark the band in each column that is still open (i.e., has to become floor or ceiling and hasn't been painted yet) Whenever a new wall is painted, they are mutated until every pixel is filled. Not only does Doom mutate them, but it’s also very important to mutate them in the right order. It’s ingenious! In the end it’s just a flood fill algorithm, but everything looks 3D basically for free (in C, that is).SQLDoom has to approach this problem differently, as we don’t have the concepts of loops or mutable state in SQL. So instead of looping, we turn to sorting and aggregating over those sorted runs - a poor man’s loop!
The things we iterate over here are called panels: One part of a wall appearing in one column of the screen.
Some panels draw something: a solid wall (solid), the wall above a door (upper), or the wall part below a window or a parapet (lower), some panels are just there to influence how other panels are rendered: If you step out of a door below a balcony, there’s something above you and that has to end somewhere.
So for each screen column (col_x) we have an ordered list of panels from near to far.
The clip state before a panel is thus defined entirely by the row preceding it. Do I smell window functions?
Since this is pretty hard to explain in text, let’s watch a video instead! Your browser does not support the video tag.
Determining the position of visplanes with window functionsHere’s the (abbreviated) SQL query:
panel_clips AS (
-- 1. 这段竖带被更近的面板遮挡后剩下的部分
SELECT p.*,
COALESCE(MAX(CASE WHEN part IN ('solid','upper','upper_flush')
THEN y_bot::int + 1 END) OVER w, 0) AS cc_before,
COALESCE(MIN(CASE WHEN part IN ('solid','lower','lower_down')
THEN y_top::int - 1 END) OVER w, screen_h - 1) AS fc_before
FROM panel_seq p
WINDOW w AS (PARTITION BY col_x ORDER BY depth_x, bsp_seq, part, seg_id
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING)
),
plane_spans_raw AS (
-- 2. 竖带中未被遮挡的部分,上方是天花板……
SELECT col_x, fsec AS sector_id, f_ceil AS plane_z, 'ceil' AS plane,
cc_before AS y0, -- 从更近墙面停止的位置开始
f_ceil_y::int - 1 AS y1 -- 直到本面板自身的天花板
FROM panel_clips
WHERE part IN ('solid','upper','upper_open','upper_flush')
AND f_ceil_y::int - 1 >= cc_before -- 没有剩余空间:跳过
UNION ALL
-- ……下方则是地板
SELECT col_x, fsec, f_floor, 'floor',
f_floor_y::int AS y0, -- 从本面板自身的地板开始
fc_before AS y1 -- 直到更近墙面停止的位置
FROM panel_clips
WHERE ...
)
我们先为场景中每个可能渲染像素的面板计算该列还有多少像素未分配。而已经可能被分配的像素,只可能来自所有更近的面板(也就是 (1) 中的 ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING 这部分)。然后我们绘制从上一个面板结束到下一个面板开始之间的像素 (2),天花板和地板都这样处理。
用相当 hacky 的手法把命令式算法伪装成基于集合的操作,对吧?幸好我们有窗口函数……
渲染地板、天花板和天空通常耗时约 3 ms。
丑陋的部分
抱歉,我不得不对你撒个小谎:墙壁、visplane 和精灵分辨率其实还什么都没画出来。它们只是生成形如 (x, y, depth, colour) 的候选项,同一坐标可能对应多个不同深度的像素。因为我们没有实现 Doom 定点数算术,不同层的墙壁、地板和天空可能会发生重叠。此外,我们还得渲染精灵,而这些精灵又可能被墙壁部分遮挡。Doom 原通过极其精密的编排确保这种情况绝不发生,从而省去了 z-buffer 的深度测试。我尝试在 SQL 中复现那套编排但失败了,最终决定退而求其次,采用暴力解法:生成所有候选,然后从中挑选赢家。
((LEAST(depth, 131071.0) * 4096)::bigint << 34) -- depth, clamped to 17.12 fixed-point
| ((2 - surface_priority) << 32) -- wall > sprite > plane
| (LEAST(source_priority, 3) << 30)
| ((stable_id + 32768) << 14) -- stable tiebreak
| (light_index << 8) | palette_index -- the payload
AS winner_key
...
SELECT pix, MIN(winner_key) FROM ranked_fragments GROUP BY pix
这和 BSP 树的套路如出一辙,把所有信息打包进一个 bigint,然后取最小值。最高位代表深度,因此直接取 min 就能找到赢家。由于像素颜色(负载)也包含在 key 中,我们甚至省去了后续的 join 操作。虽然做法有点 Hack,但考虑到这是逐像素的工作(一帧 Doom 有 320*200=64000 个像素),我们必须严格控制开销。
即便有这层优化,它仍是整帧中最昂贵的部分:平均耗时 8.2 毫秒,占整帧时间的三分之一以上。这也正是 John Carmack 当年回避此方案的原因。不过幸运的是,现在的机器性能足够强大,即便在 SQL 里跑也能稳稳击中 35 FPS 的目标。未来已来,老头!。
渲染性能
下面是渲染管线与 Doom 35 FPS 帧时间目标的对比瀑布图。

在我的笔记本(Ryzen 7 PRO 7840U)上,通常能跑到 60 FPS 左右,但在场景极其复杂时,帧率会跌至 35 FPS。
管道中最耗资源的部分(不出所料)包括:
- 渲染视线平面(需要模拟迭代算法),
- 深度解析(原初 Doom 其实成功规避了这一步),
- 以及所有逐像素处理的操作(例如:查找调色板、打包帧缓冲区)
在什么场景下使用数据库才是真正合理的选择
在数据库里渲染 Doom 显然是个馊主意。但确实有几个场景非常适合用数据库,我会为这些场景辩护到底!万物皆数据
我之前没预料到,将物品属性转化为关系型数据集竟然让我如此享受。一来,它能让你清晰看清游戏实际包含了什么;更重要的是,修改起来也极其方便。
玩家的金枪炮只是一行数据:
doom=# SELECT name, ammo_type, ammo_per_shot, pellet_count,
doom-# dmg_dice_count, dmg_dice_mult, max_range
doom-# FROM weapon_defs WHERE name = 'shotgun';
name | ammo_type | ammo_per_shot | pellet_count | dmg_dice_count | dmg_dice_mult | max_range
---------+-----------+---------------+--------------+----------------+---------------+-----------
shotgun | shells | 1 | 7 | 3 | 5 | 2048
(1 row)
发射 7 颗弹丸,每颗造成 3d5 点伤害。
连动画都是数据!以下是金枪炮的完整状态机:
doom=# SELECT state, seq_index AS seq, frame, tics,
doom-# is_attack_frame AS shoots, refire_check AS refire
doom-# FROM weapon_frames WHERE weapon_id = 3 ORDER BY state, seq_index;
state | seq | frame | tics | shoots | refire
-------+-----+-------+------+--------+--------
ready | 0 | A | 1 | f | f
fire | 0 | A | 3 | f | f
fire | 1 | A | 7 | t | f
fire | 2 | B | 5 | f | f
fire | 3 | C | 5 | f | f
fire | 4 | D | 4 | f | f
fire | 5 | C | 5 | f | f
fire | 6 | B | 5 | f | f
fire | 7 | A | 3 | f | t
fire | 8 | A | 7 | f | f
flash | 0 | A | 4 | f | f
flash | 1 | B | 3 | f | f
(12 rows)
所有东西的属性都存在表里,这也让修改任何内容变得极其容易。看看下面这段视频:我嫌伤害不够,就直接改了霰弹枪,让它一次射出 500 颗弹丸,散射范围也更大!

当然,我们本来也可以把所有数据存在 JSON 之类的文件里,但那样的话:
- 修改时无法验证约束;
- 改动生效还得重新加载。
多人联机几乎是白送的
好,我们费了这么大劲把 Doom 移植到 SQL,却还没用上数据库最大的优势:免费的多人游戏服务器!听我说,传统游戏开发者得自己搭建的一大堆东西,我们直接白拿:
- 身份认证
- 并发控制
- 权限控制
- 一致的游戏状态快照
- 二进制传输协议
一个独立的 Python referee 脚本负责驱动共享的 35 Hz 时钟并轮换地图,在线玩家的客户端则提供输入。
不过我最喜欢的部分是原子性:每次运行一个游戏 tic,只需 begin transaction,结束时 commit。每个玩家(Doom 死斗模式最多支持 4 人)看到的都是一致的视图——要么是 tic 事务开始前的世界,要么是事务完整提交后的世界。不会出现更新只应用了一半、物理引擎出错,或者对火箭到底打没打中各执一词的情况。
另一个意外优雅的部分是权限控制。sqldoom 本身有约 110 张表、100 多个函数,但四个玩家角色只允许通过少数几个定义清晰的 API 函数与之交互。我们直接 revoke 掉其他所有权限就行了!
接收玩家输入的 input 函数就是个很好的例子:
CREATE OR REPLACE FUNCTION api_input(
p_fwd real, p_strafe real, p_run boolean, p_turn real,
p_fire boolean, p_weapon integer, p_use boolean) RETURNS integer
LANGUAGE cedarscript SECURITY DEFINER AS $doom$
INSERT INTO mp_inputs
SELECT mp.map_id, mp.player_thing_id,
LEAST(1.0, GREATEST(-1.0, COALESCE(p_fwd, 0)))::real,
LEAST(1.0, GREATEST(-1.0, COALESCE(p_strafe, 0)))::real,
[...]
FROM mp_players mp WHERE mp.role_name = session_user::text;
return 1;
$doom$;
虽然这个函数拥有修改表的权限(security definer),但玩家仅能调用该函数。他们可操作的参数仅限:前进动量(是否按下 w/s)、横移(是否按下 a/d)、是否奔跑、鼠标转向、是否开火、选择哪种武器,以及是否尝试按键或开门(spacebar)。我们甚至无需信任玩家输入的具体数值:函数会将输入值限制在允许范围内。
多人模式性能也出乎意料地好:每个客户端分配 3 个核心即可保持 35 FPS 稳定运行,游戏帧间隔仍远低于预算。若再增加一个核心用于驱动游戏循环,一台 16 核机器足以轻松运行原版 -altdeath 死斗模式。
公开实例轮播第一幕地图,每 10 分钟更换新地图。即使四个玩家席位已满,你仍可从 SQL 控制台查询当前对局数据。直接游玩,或查询实时对局 →
彩蛋:编译 SQL
由 C++ 编写、解释执行 SQL 的数据库效率极低且远逊于 C 语言,这听起来合情合理。但我想评估其实际差距究竟有多大。
CedarDB 是一个编译型数据库系统:每个复杂查询都会经过多步转换,降低为 LLVM IR,最终编译为机器代码。于是我不禁好奇:生成的机器代码与原版 linux_doom 编译后的 C 代码有何差异?

上半部分展示了物体移动的逻辑及其受惯性影响的机制。左侧是原始的 Doom 源代码,右侧是 SQLDoom 的实现。 由于逻辑分布略有不同,两者并非一一对应,但 C 代码编译后仅有 48 条指令,而 SQLDoom 需要 117 条。 其中 42 条额外指令实际上是将结果再次存入表中(绿色行),这显然是 C 语言无需做的事。 说得更直白些,确实更差,但考虑到 SQL 和 CPU 之间通常隔着多少层抽象,这个差距其实没那么夸张。 对于原本从 SQL 开始、经过查询优化器再到达 LLVM 的流程,我发现这个差距出人意料地小。
John Carmack 真是个天才。
我是说,把 SQLDoom 与其前身 DOOMQL 相比(DOOMQL 类似于 Wolfenstein 3D)

两者使用相同的引擎,并面临相同的约束:输入 SQL,输出位图。别误会,DOOMQL 那种基于原语的光线投射方法很棒——用 SQL 表达更简单,步骤间依赖也少得多,更适合 SQL 的基于集的处理方式。
但事实证明,“最佳契合”并不总是带来最好的结果。 SQLDoom 采用的 BSP 树方法快得多,同时视觉保真度也更高。 这一切都源于 John Carmack 深入思考过如何在 486 处理器上通过一点“障眼法”榨取最大性能。
老实说,CedarDB 也进步了。在我构建 DOOMQL 的时候,引擎速度要慢得多,而且当时还没有基于角色的访问控制系统。
如何自行运行
项目托管在 Github:github.com/cedardb/sqldoom。
你需要以下三样东西:
- CedarDB 社区版,
- 安装了
psycopg2和pygame的 Python, - 一个 Doom IWAD 文件,这个我无法提供。Shareware 版的 doom1.wad 可以免费再分发(
apt install doom-wad-shareware),足以玩第一关,如果你拥有零售版 WAD,那些也能正常工作。
之后只要按照 README 操作,很快就能拥有自己运行的 SQLDoom!
或者,如果你觉得这些太麻烦,直接加入公共实例的对局就行:
加入对局 · 🇪🇺 欧洲服 加入对局 · 🇺🇸 美国服