我们把初代《毁灭战士》移植到了 SQL

查看原文 HN 讨论

文章摘要

这篇文章发表在数据库公司 CedarDB 的官方博客上,作者 Lukas Vogel,日期 2026 年 9 月 22 日。一句话概括:他把 1993 年初代《毁灭战士》(Doom)的游戏逻辑和渲染器都用 SQL 重写,跑在数据库里。游戏逻辑保持原版的每秒 35 次更新,渲染器在他的笔记本(AMD Ryzen 7 7840U)上能以最高 60 帧输出完整的 320×200 画面。外面那层 Python 只负责计时、读键盘、把数据库返回的位图显示出来。死亡竞赛模式也能玩,CedarDB 在欧洲和美国各架了一台公开服务器,四个座位先到先得,坐满了就排队,排队时还能用 SQL 直接查询正在进行的比赛状态。

缘起与规则。 作者去年发布过一个叫 DOOMQL 的项目,用 SQL 以 30 帧渲染出大致像《毁灭战士》的 ASCII 画面,很受欢迎。但有人指出它更像《德军总部 3D》,因为用的是光线投射;真正的《毁灭战士》用的是 BSP 树,这让正确的深度排序足够便宜,从而能负担得起贴图、任意角度的墙和高低不同的地面。他放不下这件事,于是(又一次趁着育儿假)做出了真正跑在 SQL 里的《毁灭战士》。他给自己定了五条规则:看起来要像原版;更重要的是玩起来要像原版;渲染必须纯用 SQL,输出只能是一张表或者包含每个像素精确 RGB 值的位图;游戏循环也必须纯用 SQL,但可以用数据库里的用户自定义函数;客户端可以用别的语言写,但只能负责解析输入、驱动游戏节拍和显示画面。

架构与数据导入。 游戏逻辑按固定的 35Hz 节拍运行,渲染器则是游戏状态表的纯函数,客户端想多快要画面就多快要,两者刻意分开,渲染时在两个节拍之间插值摄像机位置。作者发现 Doom 的 WAD 文件格式本身就很“关系型”:两个顶点由一条线段连接,线段有两个侧面,侧面围成扇区,扇区里放着各种物体。把整个 WAD 导入数据库只用了约 1000 行 Python,导入初代全部内容在他的笔记本上约 18 秒。文中还演示了一条 SQL 查询,以 ASCII 字符画出第一关 E1M1 的俯视图。

游戏循环。 原版每个节拍只有约 28.6 毫秒,期间要处理玩家输入、敌人移动和攻击、拾取道具、火箭弹飞行和爆炸范围、精灵渲染、动画、视角晃动和状态栏。CedarDB 有一种和 PL/pgSQL 很像的脚本语言 cedarscript,作者用它写了每个节拍的调度函数,依次调用时钟、开门、移动、死亡、武器、音效、怪物 AI、扇区特效、物体物理等子函数,每个子函数执行一批 SQL。怪物 AI 是一个状态机,写成一条带递归 CTE 的 UPDATE:判断谁活着、能看到什么、距离多远,再用 CASE 决定下一状态,比如伤害超过最大生命值就进入“血肉横飞”的爆炸死亡状态。他找到的最慢节拍出现在 E4M1,46 只醒着的怪物同时挤向一扇正在打开的门,耗时 10.45 毫秒,约占预算的 37%;有 6 只怪物的典型节拍平均只要 2.15 毫秒。游戏逻辑总共约 5900 行 SQL,比原版 C 代码实现同样功能的约 9000 行还少。他说用 SQL 写逻辑逼着人换个思路:不是逐个遍历敌人,而是写一条 UPDATE … WHERE,让数据库自己决定怎么最好地、自动并行地执行。这也让他终于理解了游戏开发里的 ECS(实体-组件-系统)模式:每个组件是一张表,每个系统是一条以实体为连接键、只连接它关心的那几张表的更新或插入语句。

渲染。 每一帧都是一个巨大的查询:输入关卡几何、游戏状态和玩家位置,输出完整的帧缓冲,即 64000 行 (x, y, rgb),最后拼成 192000 字节的一行。整个渲染器约 1300 行 SQL(不含注释),分布在 89 个 CTE 里;而 linux_doom 源码的渲染引擎约 3300 行,是它的 2.5 倍。几个关键点:

他的笔记本上通常能跑到约 60 帧,场面很热闹时会掉到 35 帧。最耗时的是 visplane(模拟迭代算法)、深度判定(原版根本没有这一步),以及所有逐像素的工作。

数据库真正擅长的地方。 作者承认在数据库里渲染 Doom 显然是个坏主意,但有几件事他“誓死捍卫”。其一,一切都是数据:霰弹枪就是表里的一行(7 发弹丸,每发 3d5 伤害,射程 2048),连开火动画的状态机都是一张 12 行的表,改起来极其方便。视频里他嫌伤害不够,直接把霰弹枪改成一次射出 500 发弹丸、散布更大。用 JSON 文件当然也能存,但那样修改时不会校验约束,改完还得重新加载。其二,多人游戏几乎是白送的:身份认证、并发控制、访问控制、游戏状态的一致性快照、二进制传输协议,数据库全都现成。他最喜欢的是原子性:每个节拍包在一个事务里,四名玩家看到的要么是节拍开始前的世界,要么是完整提交后的世界,不存在“只更新了一半”的物理 bug,也不会争论火箭到底打没打中。访问控制同样优雅:SQLDoom 约有 110 张表、100 多个函数,玩家角色却只能调用几个 API 函数,其余权限全部收回;输入函数以定义者权限运行,只接受前进、横移、奔跑、转向、开火、选武器、使用这几个参数,并把输入值钳制在合法范围内,所以不必信任客户端。每个客户端分配 3 个核心就能稳定 35 帧,再加一个核心跑节拍驱动,一台 16 核机器就能撑起一场原版规则的死亡竞赛。公开服务器每 10 分钟轮换一张第一章地图。

额外实验与结语。 CedarDB 是一个编译型数据库,复杂查询会经过多个步骤降级为 LLVM IR 再编译成机器码。作者对比了物体移动逻辑:原版 C 编译出 48 条指令,SQLDoom 是 117 条,其中 42 条是把结果写回表里,这是 C 不需要做的。他觉得对一段先经过查询优化器再到 LLVM 的 SQL 来说,这个差距小得出人意料。最后一节的标题是“约翰·卡马克是个天才”:DOOMQL 的光线投射更适合 SQL 的集合式处理,步骤间依赖也少,但 BSP 方案反而更快、画面也好得多,全靠卡马克当年绞尽脑汁,用一点障眼法榨干了 486 的性能。他也承认 CedarDB 自己进步了不少,做 DOOMQL 时引擎还慢得多,也没有基于角色的访问控制。代码在 GitHub 的 cedardb/sqldoom,需要 CedarDB 社区版、带 psycopg2 和 pygame 的 Python,以及一份 Doom 的 IWAD 文件(共享版 doom1.wad 可以自由分发,够玩第一章)。

HN 评论精华

本帖 338 分、约 50 条评论。管理员 dang 说明这篇文章此前被提交过很多次都没上首页,这次是把最早那条有评论的提交重新放了出来。讨论大多是赞叹,但最有分量的一条支线是由此引出的老问题:业务逻辑该不该放进数据库。

赞叹与调侃。 smalltorch 说“荒唐,但我爱死了”,pmkary 说“这应该是违法的”。teelinger 说这才是他想在 HN 上看到的内容,Capricorn2481 附和说总比每天的模型“更新”和大家凭五分钟印象吹捧或痛骂要强。ralfd 特别点赞他们真的在欧洲和美国各架了多人服务器。paul-vernon 注意到 5900 行 SQL 比原版约 9000 行 C 还少,好奇比 SQL 更好的关系型语言用于通用编程会怎样,AlotOfReading 推荐试试 Prolog,说写出来的程序简洁得惊人。TheOtherHobbes 说,如果眯起眼、忽略那些脑残设计,SQL 某种程度上“算是函数式的”。GuestFAUniverse 开玩笑要“Query III Arena”(谐音《雷神之锤 III 竞技场》),antonvs 说别加 /s,是真想要,dclavijo 还为这个名字写了个正则。goosethe 激动地喊“我的同类!”,晒出自己在 Postgres 里实现的 shell、GPT-2、操作系统和 git。snarfy 想起了用 C# LINQ 写的光线追踪器。

这是不是软文? d–b 说这是一则漂亮的广告,在一众 SQL 数据库里很难脱颖而出,这确实让人眼前一亮。vovavili 说他最近正在找一个可以自建、语法兼容 Postgres 的 HTAP(混合事务与分析处理)系统来替代 TiDB 加 TiFlash,LLM 还没“学到” CedarDB,“这帮人懂营销”。pavlov 的评论更刻薄:“我们用[训练数据里极其常见的语言]做了[训练数据里极其常见的东西]”,似乎是 AI 时代最能博眼球的形式。threethirtytwo 也说有了 LLM 这些都不再令人惊艳,除非有人用 CSS 从零做出一个《巫师 3》级别的 3A 大作;komodo99 甩出一个用 CSS 实现的 x86 模拟器,他回答“不算”。island_dev 问能否换成其他数据库引擎运行,没人回答。

游戏状态放在 SQL 表里。 noduerme 说这勾起了他一年优化工作的回忆:2010 年他写一个在线赌场时,让每个远程调用都去更新作为唯一事实来源的 SQL 游戏状态表,早期的死锁问题“惨不忍睹”,扩展是一场噩梦;但一切都是原子的,最多丢一个回合,不会出现一个巨大的 Node.js 进程被所有人的调用压垮或丢失内存的情况。回头看,他觉得对多人回合制游戏来说这不是个糟糕的模式,只要能解决那些问题;但对动作游戏也这么读写,就纯属胡闹了。vovavili 推荐他读《设计数据密集型应用》讲事务并发的那一章。

业务逻辑该不该放进数据库? 这是讨论最深入的部分。bob1029 认为 HN 严重低估了现代 SQL 的能力:有些业务复杂到用过程式代码维护几乎不可行,用 SQL 实现业务规则能把问题拆解得让更多人同时参与。他以前在半导体制造业工作,工厂运作高度依赖存储过程和 SQL,代码里几乎没有运营决策逻辑,几百个用户同时审阅和提议修改同一批存储过程;测试也很简单,每天早上复制一份生产库,直接拿真实数据做实验。他认为大多数公司把数据库当成增删改查的检索引擎,而人们愿意花大钱买微软、甲骨文、IBM 的东西,图的就是一个让整个业务都在里面运转的系统。反对的声音不少:dmos62 同意数据库内的保证能解决整类问题,但在 Postgres 里写复杂逻辑的开发体验并不好;viraptor 说他最不想做的事之一就是用 SQL 写复杂逻辑,在 MSSQL 这种字符串化、标准库贫乏的环境里更是如此,这种模式不流行是有原因的,他提到 SpacetimeDB 的函数是个替代方案;Ntrails 补刀说,还有更糟的:他维护过的那段逻辑是夹满“+ @database_name +”的巨型拼接字符串,多年间长到了 2000 行。ambicapter 指出大多数公司数据库里存的是海量数据,每天复制一份生产库并不现实,tosti 说一些大型电信公司就在这么做。jcmontx 回忆 2010 年代维护 2000 年代遗留应用的存储过程,代码不被当成正常代码对待,甚至不进版本控制。beachy 有同样的记忆,但也说没什么比这更让人满足:远处某个团队报告说数据提交不进去,结果发现正是你的参照完整性约束阻止了他们写坏数据库;他认为在 AI 让大量代码涌向数据库的时代,把逻辑和约束嵌在数据库里、让谁都绕不过去,可能反而更有道理。pak9rabid 说他负责的数据分析平台约 95% 的逻辑都写成了数据库函数和存储过程,剩下 5% 的 Python 只负责把 HTTP 请求接到这些函数上并输出成 XLSX,现在他还在用 DuckDB 替换部分重分析任务,“SQL 万岁”。_heimdall 的看法比较折中:逻辑放在哪一层是个有趣的架构问题,最复杂、最常变的逻辑他倾向于放在应用层,同时在数据库里加约束作为最后防线;他也坦言,能在 SQL 层轻松接手复杂业务逻辑的人,他一只手就数得过来。portender 则认为这是文化选择而不是技术选择,把逻辑和数据模型搅在一起是一场“沼泽探险”,他敬而远之。