SQLDoom:在 SQL 数据库中完整运行原始 1993 年 Doom 引擎

TL;DR

SQLDoom 通过仅使用 SQL 查询,在 CedarDB 中完全运行原始 1993 年 Doom 的游戏逻辑和渲染器,保持经典的 35 Hz 游戏循环频率,并以高达 60 FPS 的速度输出完整的 320×200 帧缓冲区;一个轻量级的 Python 客户端负责处理输入、定时和位图显示,同一引擎还支持四人死亡竞赛服务器。


SQLDoom 是什么

SQLDoom 是对 Doom 第一章节共享版的忠实移植,将 所有 游戏逻辑、状态和渲染都移入由 CedarDB 数据库引擎执行的 SQL 语句中。唯一的外部代码是一个极简的 Python 脚本,其功能包括:

  1. 通过 pygame 读取键盘/鼠标输入。
  2. 每隔 1/35 秒调用一次 SELECT doom_run_game_tic(...) 以推进世界状态。
  3. 通过单个 SQL 查询请求新帧,并绘制返回的位图。

其余所有内容——怪物 AI、玩家移动、武器处理、BSP 遍历、墙壁/可视平面光栅化——均存在于表、存储过程和大量使用 CTE 的查询中。


架构概览

该系统分为两个独立的处理管道:

  • 游戏逻辑管道 – 以固定的 35 Hz 运行,更新关系型游戏状态表,并不返回任何内容,仅返回更新后的状态。
  • 渲染管道 – 一个纯函数型 SQL 查询,读取当前状态表并输出包含每个像素 (x, y, rgb) 的 64 000 行结果。
Python (pygame) ↔️ SQL 逻辑表 ↔️ SQL 渲染器 → 位图 → 显示

这种分离使得客户端可以按需请求帧(作者报告最高可达 60 FPS),而逻辑部分仍严格锁定在原始的 28.6 ms 每帧预算内。


将 Doom 数据加载到关系模型中

Doom 的 WAD 格式已能清晰映射到关系表中:

  • VERTEXES → vertexes
  • LINEDEFS → linedefs
  • SIDEDEFS → sidedefs
  • SECTORS → sectors
  • THINGS → things

一个 1000 行的 Python 导入器解析 WAD 文件,并在笔记本电脑上约 18 秒内填充约 110 个表。关系模式使得关卡几何结构可直接查询,博客文章中包含的鸟瞰 ASCII 渲染查询即为示例。


游戏循环实现

一个名为 doom_run_game_tic 的存储过程封装了整个游戏循环。内部,CedarScript(一种类似 PL/pgSQL 的语言)构建一个 位掩码计划,决定本帧需要运行哪些子系统。随后,该过程调用一系列函数,例如:

  • doom_cs_move – 玩家和怪物移动
  • doom_cs_doors – 门的开启/关闭
  • doom_cs_weapon – 武器状态、视线扫描、伤害计算
  • doom_cs_monsters – 怪物 AI 状态机
  • doom_cs_sector_fx – 区域效果(地板、天花板)

怪物 AI 示例展示了递归 CTE 如何计算视线、距离,并通过 CASE 表达式决定下一状态,最终通过单个 UPDATE 更新 monster_ai 表。

性能:最坏情况(E4M1,46 个清醒怪物)耗时 10.45 ms(约占 28.6 ms 预算的 37%)。典型情况(6 个怪物)平均耗时 2.15 ms(约占 8%)。整个逻辑共约 5 900 行 SQL,少于原始 C 源码的约 9 000 行。


SQL 中的渲染管道

一个包含 89 个 CTE 的单一 SQL 查询生成完整的帧缓冲区。该管道模拟了 Doom 的经典步骤:

  1. BSP 遍历 – 预计算的 node_path_steps 物化视图将每个根到子区域路径编码为 40 位 sort_key。按此键排序即可获得无需递归的从前到后的子区域序列。
  2. 墙壁光栅化 – generate_series 将每个墙段展开为屏幕列,再展开为每个像素的片段。
  3. 可视平面(地板/天花板)填充 – 窗口函数计算每列剩余未裁剪区域,然后输出天花板/地板跨度。
  4. 精灵渲染 – 为广告牌生成类似片段。
  5. 深度解析 – 所有候选片段打包进 64 位 winner_key,其中最高有效位编码深度、表面优先级和稳定平局解决机制。MIN(winner_key) 按像素选择可见片段。

时间分解:

  • 墙壁光栅化 – ~1.7 ms
  • 可视平面填充 – ~3 ms
  • 深度解析与像素级工作 – ~8.2 ms(最大开销)
  • 平均帧总耗时 – ~16 ms,Ryzen 7 7840U 上可实现 ~60 FPS;繁忙场景降至原始 35 FPS。

通过数据库实现多人游戏

由于整个世界存在于事务性数据库中,多人游戏几乎是免费的:

  • 一个单一的“裁判”进程为所有玩家驱动 35 Hz 时钟。
  • 玩家输入通过 SECURITY DEFINER 函数 api_input 提交,该函数对值进行限制并插入 mp_inputs 表。
  • 每帧在 BEGIN … COMMIT 块内运行,确保每个客户端看到的要么是帧前状态,要么是帧后状态——无部分更新。
  • 基于角色的访问控制限制玩家仅能使用 API 函数,防止任意表操作。

公共服务器在每客户端 3 核配置下运行四人死亡竞赛,稳定 35 FPS;16 核机器可轻松托管额外比赛。


为何数据库可能是合适的选择

  • 数据驱动设计 – 武器、怪物和动画帧作为表中的行,使得检查和实时调整变得轻而易举。
  • 原子性与一致性 – 每帧是一个事务,消除了经典的异步错误。
  • 访问控制 – 细粒度权限可仅向玩家暴露预期的 API。
  • ECS 映射 – 实体变为行,组件变为表,系统变为基于集合的 UPDATE/INSERT 语句,利用数据库的并行执行引擎。

编译开销

CedarDB 在执行前将复杂查询编译为 LLVM IR。与原始 C 代码的汇编对比显示,SQL 生成的代码使用 117 条指令,而原始 C 仅 48 条,额外指令主要归因于将结果写回表中。考虑到多层抽象,这一差距并不显著。


社区反响(Hacker News 精选)

"我惊讶于在 SQL 中表达复杂游戏逻辑竟然如此简单。游戏逻辑仅约 5900 行 SQL。" – bob1029

"代码行数少于原始 C,同时滥用查询计划作为状态机,这是工程上的极致滥用。我爱它。" – soltanov

"游戏状态是一个 SQL 表,这让我想起了自己一年的优化经历……原子性保证了状态一致性……为动作游戏做这种读写循环?纯粹的愚蠢,但我觉得挺有趣的。" – noduerme

这些评论凸显了人们对 SQL 表达能力的惊讶、将查询计划器用作状态机的优雅,以及在实时动作游戏中可扩展性权衡的思考。


快速上手

  1. 安装 CedarDB 社区版。
  2. 安装 Python 依赖:psycopg2 和 pygame。
  3. 获取共享版 doom1.wad(例如:apt install doom-wad-shareware)。
  4. 克隆仓库 https://github.com/cedardb/sqldoom,并按照 README 加载 WAD 和启动 Python 驱动程序。

或者,加入原始博客文章中链接的欧洲或美国公共服务器进行对战。


总结

SQLDoom 证明了关系型数据库可以托管一个功能完整、实时的 3D 引擎:游戏逻辑自然地融入基于集合的更新,基于 BSP 的渲染可用 CTE 和窗口函数表达,事务性保证使多人游戏变得简单。尽管数据库并非生产游戏的实用图形引擎,但该项目是一个令人信服的概念验证,突破了 SQL 可表达性的边界。

Sources

相关

  • 项目
  • 项目
  • 项目
  • Dispatch
  • 项目