← 文章 / 数据与数据库
Hacker News 1小时前 · 2026-10-05 17:30:09 · 7 阅读

SQL移植版Doom:将1993经典游戏跑在数据库里

TL;DR:我们把 1993 年初代 Doom 的游戏逻辑和渲染器移植到了 SQL,并在数据库里运行起来。 游戏循环保持原版的 35 FPS,渲染器在我的笔记本上能以最高 60 Hz 输出完整的 320x200 帧缓冲。 Python 只负责计时、读取键盘和显示返回的位图。多人联机也能玩。

您的浏览器不支持 video 标签。AMD Ryzen 7 7840U 上运行的 SQLDoom

现在就能玩 死亡竞技模式,四个名额,先到先得。

SQLDoom 🇪🇺 欧洲服务器

SQLDoom 🇺🇸 美国服务器

这是第一关的共享软件版。如果名额满了,你会进入排队队列。 即使排队,你也可以通过 SQL 实时查询游戏状态,随便看看。

SQLDoom

去年,我发布了 DOOMQL [Github]。 它以 30 FPS 渲染出一些大致像 Doom 的 ASCII 画,反响很不错。 但有人正确地指出,它其实更接近 Wolfenstein 3D 而不是 Doom,因为它用的是光线投射(raycasting)方法。 而 Doom 使用的是 BSP 树,让深度排序的代价足够低,从而能支持贴图、任意角度的墙面和高低起伏的地板。

这事我一直放不下,折腾了一阵子之后(你猜对了,又休育儿假了),我终于可以让真正的 Doom 完全跑在 SQL 里了。

One of these is the 1993 binary. The other is a SQL query. Can you figure out which is which?

其中一个是 1993 年的原版程序,另一个是 SQL 查询。你能分清哪个是哪个吗?

规则

先定几条我们要达成的底线规则:

  1. 画面要像真的 Doom。现在回头看,DOOMQL 的视觉还原度实在有点拿不出手。
  2. 但更重要的是,玩起来也要像真的 Doom。原版游戏的乐趣是纯粹的。
  3. 渲染必须完全基于 SQL。可接受的 SQL 输出只有一种:一张为每个像素编码精确 RGB 值的表或位图。
  4. 游戏主循环也必须完全基于 SQL 实现。不过,允许在数据库内部使用用户定义函数。
  5. 允许用其他编程语言编写客户端,前提是它只负责解析输入、驱动游戏帧循环以及渲染输出位图。

架构

Python 被刻意设计得很简单无趣(规则 5)。单个脚本利用 pygame 驱动输入、绘制输出位图,并以每秒 35 次的频率触发游戏帧。 游戏逻辑、游戏状态和渲染器都驻留在数据库内部。

                         Python
                 input / timing / display
                    |              ^
                    |              |
          run game tic        request frame
                    |              |
                    v              |
          +----------------+  +----------------+
          |                |  |                |
          | SQL game logic |  |  SQL renderer  |
          |                |  |                |
          +-------+--------+  +--------+-------+
                  |                    ^
                  |                    |
                  v                    |
             +-----------------------------+
             |                             |
             |       game state tables     |
             |                             |
             +-----------------------------+

两条路径被刻意分离:游戏逻辑运行在 35 Hz 的固定循环上, 而渲染器是游戏状态表的纯函数,客户端可以随时(尽可能快、尽可能频繁地)请求新帧。

加载游戏数据

便利的是,Doom 的 .wad 文件格式本身具有高度的关系特性。

两个 VERTEXES 由一条 LINEDEF 连接,该 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 units per column
         round((v1.y + (v2.y - v1.y) * t / 32.0) / 96) AS row, -- chars are 2:1
         l.left_sd_id < 0 AS solid -- one-sided lines are pass-through
  FROM linedefs l, generate_series(0, 32) AS t        -- walk each line in 32 steps
  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 的画面。 当然,视觉效果是大头,但 Doom 玩起来那种爽感也是核心体验。 来看看下面这个场景,这是规则 2 的实际表现(我玩得挺开心):

Gibbing 3 soldiers with a rocket launcher

Gibbing 3 soldiers with a rocket launcher

如你所见,画面信息量很大。仅在这段短视频里就能看到:

  • 需要轮询并处理玩家输入(移动、转身、射击),
  • 敌人会移动和攻击,
  • 物品会被拾取,
  • 火箭发射器会射出移动的弹体,
  • 火箭爆炸带有冲击波范围,
  • 需要渲染敌人精灵图,
  • 动画、视角晃动以及 HUD

我们处理这些逻辑的时间非常有限: 原版 Doom 运行在 35 Hz 的固定时钟上,因此每个 tick(节拍)的预算为 1000 ms × 35 Hz = 28.6 ms。 同时它每个 tick 恰好绘制一帧,所以上限也是 35 FPS。

SQLDoom 将游戏逻辑保持在 35 Hz(以便所有原始常量继续有效),但将渲染过程解耦。 客户端可随时查询(get it?)获取一帧,我们则在不同 tick 之间对相机位置进行插值。 因此有两项预算需要关注:

  • 每 28.6 ms 执行一次 tick(否则手感会完全不对)
  • 至少每秒渲染 35 帧(低于此值尚可接受,但会显得不流畅)

Tick 执行序列

游戏 tick 本质上是过程式的。每次执行 tick 时,我们必须按顺序执行一系列操作。 CedarDB 有一种名为 cedarscript 的脚本语言,它类似于 PL/pgSQL,允许我们预先规划每个 tick 的执行步骤。

以下是 tick 函数的一小段代码:

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 驱动程序每秒调用 SELECT doom_run_game_tic(...) 35 次。

这些被调用的函数接着执行一批 SQL 语句。 下面是怪物 AI 状态机的部分示例。

-- 节选自 sql/runtime/functions/26_cs_monsters.sql。
WITH RECURSIVE
  monsters AS ( [...] ),   -- 谁还活着、什么类型、在哪里
  los      AS ( [...] ),   -- 可见性、视野范围内、距离:递归实现,会沿墙搜索
  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 毫秒,约占可用时间预算的 37%。

而一个更典型的情况——6 只怪物苏醒——平均每次 tic 只需 2.15 毫秒,约占预算的 8%。余量非常充足!

说实话,我没想到用 SQL 表达相当复杂的游戏逻辑会这么容易。整个游戏逻辑只有大约 5900 行 SQL。听起来不少,但完成同样功能的原始 C 源码可是要 9000 行左右呢!

SQL 还迫使你换一种思维模式来思考问题。比如处理敌人时,你不再需要逐个遍历,只需写一条简单的 `UPDATE ... WHERE condition`,剩下的就交给数据库去并行自动处理!

这也让我终于顿悟了 实体组件系统(ECS) 的设计模式。在这个架构里,每个 `entity`(玩家、怪物、物件等)由多个 `components`(位置、贴图、属性等)组成,而 `system`(怪物 AI、玩家移动、伤害计算等)则决定拥有特定属性组合的实体之间如何交互。ECS 的核心在于数据局部性,以及如何高效遍历拥有特定组件集合的实体。而 SQL 恰恰擅长处理这类密集型数据!每一个组件对应一张表,每一个系统则是一条 `update` 或 `insert` 语句,它只关联自己关心的表,并以实体作为连接键即可。

渲染

每一帧本质上就是一个巨大的视图:它读取关卡几何结构、游戏状态和玩家位置作为输入,返回完整的帧缓冲。以下是整个渲染流程的草图:

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 的剔除(culling),右半部分则可视化了墙壁渲染和可见平面(visplanes)。

BSP 遍历

1993 年没人拥有带硬件加速 Z-buffering 的 GPU, 因此 Doom 必须通过按正确顺序绘制来处理遮挡。 Doom 采用的方法相当巧妙:它从前往后绘制,并跟踪哪些像素已经绘制过(换句话说,如果某个墙体像素已画好,就不必再画后面的怪物)。 但说起来容易做起来难:我们需要一种高效的方法,将关卡中的所有元素按深度排序。

Doom 通过预先计算并烘焙到 doom.wad 文件中的 BSP 树来获取这种排序。 树的每个节点是一条将地图一分为二的分割线。地图的扇区(sectors)因此被切割成许多位于这些线条两侧的次级扇区(subsectors),随后插入树中,从而满足以下属性:

  1. 每个次级扇区都是叶子节点,并且
  2. 每个次级扇区都是凸的(即在内部任意位置都能看到所有墙壁)
  3. 在每个树节点处,位于摄像机一侧的整棵子树保证位于另一侧子树的前方。

通过递归遍历 BSP 树,我们就能得到所有次级扇区的从前往后的顺序。这直接决定了渲染顺序: 一旦某个屏幕区域被更近的物体覆盖,后面的物体就可以被跳过。

这是动态效果演示(你可能需要全屏观看): 您的浏览器不支持 video 标签。

BSP 遍历的可视化

左侧按从前到后的顺序排列了 subsector,视野外的 BSP 分支会被直接裁剪掉。中间是 SQLDoom 给每个区域分配的顺序,右侧是最终生成的画面,墙面颜色对应其所在的 subsector。

中间那幅图展示了 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) 会过滤掉所有存在不合格祖先节点的 subsector。

管线中后续的所有步骤都通过 bsp_seq 做 join,因此只处理可见的 subsector,且顺序正确。

Walls 与 Visplanes

Doom 有点作弊的味道:它看起来是 3D 的,实际上是个 2.5D 游戏。 本质上就是一个平面,配上完全垂直的墙和始终与地面平行的天花板。 这让渲染比真正的 3D 引擎简单得多:

  1. 绘制所有墙(从前往后,如前文所述)
  2. 还没画到的部分,不是地板就是天花板,把它们画上。
  3. 精灵(怪物、桶、道具)都是始终面向你的平面贴图(想想纸板剪影),所以不需要复杂的变换(它们与墙重叠时除外,后面会讲)。

墙体

一面墙占据屏幕上若干连续的列,在每一列内是一段连续的像素。 所以我们可以用 generate_series() 展开行和列,从前往后一面一面地画墙:

columns AS ( -- 为墙 w 覆盖的每个屏幕列生成一行
  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 ( -- 该列中墙覆盖的每个像素生成一行
  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 毫秒。

Visplanes

墙画完了,接下来聊聊有趣的部分:地板和天花板,Doom 里称之为 visplanes。

不幸的是,Doom 的渲染算法很难直接移植到 SQL,因为它具有强烈的命令式特征: Doom 维护了两个数组 ceilingclip 和 floorclip,每个屏幕列各有一条记录。 它们标记每列中尚未渲染的“开放”区域(即还需要绘制为地板或天花板的部分)。 每当绘制一面新墙时,这两个数组会被修改,直到所有像素被填充完毕。 Doom 不仅会修改这两个数组,而且修改的顺序至关重要。这设计得很巧妙!归根结底,这只是个泛洪填充(flood fill)算法,但基本上就免费获得了 3D 视觉效果(当然,前提是用 C 语言实现)。

SQLDoom 必须用不同的方式处理这个问题,因为 SQL 没有循环或可变状态的概念。 所以我们不循环,而是对排序后的结果集进行排序和聚合——一种穷人式的循环!

这里遍历的对象被称为面板(panels):墙面在屏幕某一列中显现的某一部分。 有些面板负责绘制内容:实心墙(solid)、门上方的墙面(upper)、或窗户/女儿墙下方的墙面部分(lower);有些面板则只用来影响其他面板的渲染方式:例如你站在阳台下方的门口,上方有遮挡物,而它得在某处结束。

因此,对于每个屏幕列(col_x),我们都有一份从近到远的面板有序列表。 某个面板之前的裁剪状态,完全由它前面那一行的数据定义。难道我闻到了窗口函数的味道?

光用文字解释这块相当困难,咱们直接看视频吧! 您的浏览器不支持视频标签。

使用窗口函数确定视平面(visplanes)的位置

下面是(简化后的)SQL 查询:

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),天花板和地板均如此处理。

这种将命令式算法伪装成集合操作的方式相当粗糙,好在我们有窗口函数……

渲染地板、天花板和天空通常耗时约 3 ms。

丑陋的部分

很遗憾,我之前骗了大家:墙壁、视平面(visplane)和精灵(sprite)的解析目前还不会真正绘制任何东西。它们只是输出形如 (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 就能找到胜者。而且因为 payload(即像素颜色)也包含在 key 中,我们甚至不需要再做一次 join!这招有点取巧,但由于这是逐像素的操作(单帧 Doom 有 320*200=64000 个像素),我们必须小心控制计算量。

即便做了这个优化,它仍是整帧中最昂贵的部分:平均耗时 8.2 毫秒,占整帧时间的三分之一以上。这也正是 John Carmack 当初刻意避开的原因。不过现在运气不错,即便是用 SQL 运行,机器也足以达成 35 FPS 的目标。未来已来,老人家!

渲染性能

下面展示了与 Doom 35 FPS 帧目标对比的流水线瀑布图。The rendering pipeline on an AMD Ryzen 7 7840U

The rendering pipeline on an AMD Ryzen 7 7840U

在我的笔记本(Ryzen 7 PRO 7840U)上,通常能跑约 60 FPS,但在画面特别复杂的场景下会掉到 35 FPS。

整条渲染流水线中开销最大的部分(不出所料)是:

  • 渲染 visplane(我们需要模拟一个迭代算法)
  • 深度解析(原版 Doom 本来就巧妙地避开了这一步)
  • 以及所有需要逐像素处理的操作(比如查 colormap、打包 framebuffer)

数据库真正派上用场的地方

在数据库里渲染 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)

七颗弹丸,每颗造成 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)

万物皆存于表中的特性,让修改几乎所有内容都变得极其容易。 看看下面这段视频,我因为伤害不足感到沮丧,于是直接给霰弹枪打个 Mod,让它一次发射 500 发子弹,扩散角度更大!

Modding the shotgun

Chea...Modding the shotgun

当然,我们也可以把所有数据以 JSON 等格式存储在文件里,但那样意味着

  1. 约束在修改时不会被校验,且
  2. 必须重载才能生效。

多人联机几乎免费获得

好吧,费这么大劲把 Doom 移植到 SQL,却还没用上数据库最大的优势:免费拥有一个多人服务器! 听我说,我们免费获得了很多传统游戏开发者必须自行构建的东西:

  • 身份认证
  • 并发控制
  • 访问控制
  • 游戏状态的一致性快照
  • 二进制网络协议

一个独立的 Python referee 脚本驱动共享的 35 Hz 时钟并轮换地图。在线玩家客户端提供输入。

不过,我最喜欢的部分是原子性:每次运行游戏 tick 时,只需执行 begin transaction,最后 commit 即可。 每位玩家(Doom 死斗模式支持最多 4 人)始终获得一致视图:要么是 tick 事务开始前世界的样子,要么是完全提交后的样子。不存在部分更新的逻辑,没有物理 Bug,大家也不会对火箭到底有没有命中产生分歧。

第二个让人惊讶其优雅的点是访问控制。 虽然 sqldoom 本身约有 110 张表和 100 多个函数, 但四种玩家角色仅被允许通过少数几个定义良好的 API 函数与之交互。 我们对其他所有权限直接撤销!

接收玩家输入的 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,且游戏 tick 仍远低于预算值。若为 tick 驱动器额外分配 1 个核心,一台 16 核机器就足以运行原版 -altdeath 死亡竞赛模式。

公共实例会循环轮换第一章节的地图,每 10 分钟切换一张新地图。即使四个玩家槽位已满,你仍可通过 SQL 控制台查询实时对局信息。 游玩或查询实时对局 →

彩蛋:编译 SQL

用 C++ 编写的数据库解释执行 SQL,效率肯定极低,无法与 C 语言相提并论? 可能确实如此,但我想评估它到底相差多少。

CedarDB 是一个编译型数据库系统:每个复杂查询都会经过多步流程降低为 LLVM IR,随后编译为机器代码。因此,我提出了一个问题:生成的机器代码与原始编译的 linux_doom C 代码有何不同?

Comparison between compiled linux_doom and SQLDoom

编译版 linux_doom 与 SQLDoom 的对比

上半部分展示了一个物体的移动逻辑,以及动量对它的影响。左边是原版 Doom 的源代码,右边是 SQLDoom 的实现。两者并不是严格一一对应的,因为逻辑的分布略有不同,但这段 C 代码编译后只有 48 条指令,而 SQLDoom 需要 117 条。其中多出来的指令里有 42 条其实是把结果重新写回表(绿色部分),C 代码自然不需要这一步。所以它确实更慢,这一点要说明白,但考虑到 SQL 和 CPU 之间通常隔着好几层抽象,这个差距其实不算太大。毕竟这段代码从 SQL 出发,经过查询优化器,最后才到 LLVM,能有这样的表现已经出乎我的意料了。

John Carmack 是个天才。

不信的话,拿 SQLDoom 和它那个类似 Wolfenstein 3D 的前辈 DOOMQL 比比看:DOOMQL vs SQLDoom

DOOMQL vs SQLDoom

两者用的是同一个引擎、同样的约束:输入是 SQL,输出是位图。必须承认,DOOMQL 那种朴素的 raycasting 方案很了不起——用 SQL 表达起来容易得多,步骤之间的依赖也更少,天生更适合 SQL 基于集合的处理方式。

但事实证明,“最合适”的方案未必是效果最好的方案。SQLDoom 的 BSP-tree 方案不仅快得多,画面精细度还更高。这一切都要归功于 John Carmack 当年苦心钻研,靠一点障眼法就榨干了 486 的性能。

另外说实话,CedarDB 也跟上了时代的脚步。我做 DOOMQL 的时候,引擎还慢了不少,也还没有基于角色的权限系统。

如何自己运行

项目托管在 Github 上:github.com/cedardb/sqldoom。

你需要准备三样东西:

  1. CedarDB Community Edition;
  2. 装了 psycopg2 和 pygame 的 Python;
  3. 还有一个我没办法提供给你的 Doom IWAD。共享版的 doom1.wad 可以自由分发(apt install doom-wad-shareware),足够玩第一章;如果你拥有零售版 WAD,也可以直接使用。

之后就照着 README 操作,很快就能跑起你自己的 SQLDoom 了!

如果觉得自搭太麻烦,直接加入公共实例的多人游戏:

加入游戏 · 🇪🇺 欧洲 加入游戏 · 🇺🇸 美国
原始来源: Hacker News

评论 (0)