ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用SQL实现《毁灭战士》游戏引擎:关系型数据库的计算能力边界探索

用SQL实现《毁灭战士》游戏引擎:关系型数据库的计算能力边界探索 1. 当游戏引擎被塞进数据库SQLDoom 到底在做什么第一次听说有人把《毁灭战士》跑在 SQL 里我的反应和大多数人一样——这不是吃饱了撑的吗但仔细看完这个项目的实现思路之后我改变了看法。它不是在搞行为艺术而是在用一种极端的方式回答一个正经问题关系型数据库的计算能力边界到底在哪里SQLDoom 做的事情简单说就是把初代《毁灭战士》的完整游戏逻辑——包括地图数据管理、玩家移动、碰撞检测、怪物 AI、武器射击判定、甚至画面渲染——全部用 SQL 语句来实现。你连一条SELECT查询它返回的不是数据行而是下一帧的游戏状态。整个游戏循环变成了数据库的查询-更新-查询的往复过程。这听起来很疯狂但它的技术价值在于SQL 本质上是一种声明式的数据变换语言而游戏逻辑本质上也是状态变换。两者在数学层面是同构的。SQLDoom 把这个同构关系推到了极致用数据库的SELECT、INSERT、UPDATE、JOIN、窗口函数、递归 CTE 等特性硬生生拼出了一个完整的游戏运行时。适合谁看如果你是对数据库原理感兴趣的后端开发这篇文章会帮你重新理解 SQL 的表达能力如果你是游戏开发爱好者你会看到一个完全反直觉的游戏架构如果你只是觉得这事好玩那也够了——毕竟把《毁灭战士》跑在 SQL 里这件事本身就值得你花十分钟了解一下它是怎么做到的。2. 为什么是 SQL从图灵完备到状态机的映射逻辑2.1 SQL 的图灵完备性不是理论空话很多人知道 SQL 是图灵完备的但很少有人真正利用这一点。图灵完备意味着只要给足够的时间和存储空间SQL 能计算任何可计算函数。递归 CTECommon Table Expression是实现这一点的关键——它允许查询引用自身形成递归结构这正是实现循环和状态迭代的基础。SQLDoom 的核心循环就是一个递归 CTE。每一帧的游戏状态作为输入经过一系列 SQL 变换后输出下一帧状态然后递归调用自身。伪代码大概长这样WITH RECURSIVE game_loop(frame, state) AS ( -- 初始状态 SELECT 0, initial_state UNION ALL -- 递归每一帧调用一次游戏逻辑更新 SELECT frame 1, update_game_state(state) FROM game_loop WHERE frame max_frames AND NOT game_over(state) ) SELECT * FROM game_loop;这个结构看起来简单但update_game_state这个函数在 SQL 里展开后是几百行嵌套的子查询、JOIN 和 CASE WHEN。每一帧要处理玩家输入、移动碰撞、怪物行为、子弹轨迹、伤害计算、地图状态更新——全部在一个 SQL 语句里完成。2.2 游戏状态如何用关系表表达关系型数据库的核心是表。SQLDoom 把游戏世界的所有实体都映射成表游戏实体对应表关键字段玩家playersid, x, y, angle, health, ammo, weapon怪物monstersid, type, x, y, health, state, target_id子弹projectilesid, x, y, dx, dy, owner_id, damage地图格子map_cellsx, y, wall_type, floor_height, ceiling_height游戏全局状态game_statecurrent_frame, level, score, game_over每一帧的更新本质上就是对这些表做批量UPDATE和INSERT。比如玩家移动就是根据当前角度和速度计算新坐标然后检查新坐标是否撞墙——撞墙就回退没撞就更新。这个逻辑用 SQL 写出来大概是UPDATE players SET x CASE WHEN NOT EXISTS ( SELECT 1 FROM map_cells WHERE map_cells.x players.x speed * COS(angle) AND map_cells.y players.y speed * SIN(angle) AND map_cells.wall_type ! 0 ) THEN players.x speed * COS(angle) ELSE players.x END, y CASE WHEN NOT EXISTS ( SELECT 1 FROM map_cells WHERE map_cells.x players.x speed * COS(angle) AND map_cells.y players.y speed * SIN(angle) AND map_cells.wall_type ! 0 ) THEN players.y speed * SIN(angle) ELSE players.y END WHERE id 1;这段 SQL 做的事情和 C 语言里几行代码完全等价但表达方式截然不同。SQL 版本的优势在于它是声明式的数据库查询优化器可以自动决定执行顺序和索引使用劣势在于对于逐元素的顺序逻辑SQL 写起来极其啰嗦。2.3 渲染器为什么也能用 SQL 实现这是最让人震惊的部分。传统游戏渲染需要逐像素计算光线投射raycasting在 C 语言里是一个三重循环对屏幕每一列发射一条射线沿射线步进检测墙壁计算距离后绘制垂直条纹。SQLDoom 用了一个非常聪明的技巧把光线投射转化为集合运算。具体做法是生成一个包含所有屏幕列和所有可能射线步进距离的笛卡尔积表对每一行计算该步进位置是否有墙壁用窗口函数找出每列第一个撞墙的位置根据距离计算墙壁高度和纹理坐标输出为像素颜色值这个查询在概念上是一次性的集合操作而不是逐像素循环。数据库引擎在执行时可能会做全表扫描但逻辑上它是声明了每个像素应该是什么颜色而不是命令计算机去逐个计算。这里有个关键点SQLDoom 的渲染输出通常不是直接显示在屏幕上的而是生成一个像素颜色表然后由外部程序读取这个表并绘制到窗口。SQL 负责的是计算每个像素应该是什么颜色而不是把颜色画到屏幕上。3. 从地图数据到像素颜色SQLDoom 的完整数据流拆解3.1 地图数据的表结构设计初代《毁灭战士》的地图是 2.5D 的平面是二维网格但每个格子有地板高度和天花板高度墙壁有纹理。SQLDoom 需要把这些数据全部塞进关系表。地图表的设计直接决定了后续所有查询的性能。我见过一些实现把地图存成一个大文本字段每次查询都要解析——那是灾难。SQLDoom 的做法是把地图拆成map_cells表每个格子一行CREATE TABLE map_cells ( x INT NOT NULL, y INT NOT NULL, wall_type INT DEFAULT 0, -- 0 表示空地非 0 表示墙壁类型 floor_height INT DEFAULT 0, ceiling_height INT DEFAULT 128, floor_texture INT DEFAULT 1, ceiling_texture INT DEFAULT 1, wall_texture INT DEFAULT 1, PRIMARY KEY (x, y) );这个设计的关键在于主键是 (x, y) 复合索引这样任何基于坐标的查询都能走索引。光线投射时每一步都要查这个格子是不是墙如果没有索引每帧几万次查询会让数据库直接跪。3.2 光线投射的 SQL 实现细节光线投射的核心是对屏幕每一列从玩家位置沿该列对应的角度发射一条射线逐步前进直到撞墙。在 SQL 里这个逐步前进被展开成一个数字序列WITH RECURSIVE ray_steps(col, step, x, y, hit) AS ( SELECT col, 0, player.x, player.y, 0 FROM screen_cols, players WHERE players.id 1 UNION ALL SELECT col, step 1, x COS(angle_for_col(col)) * step_size, y SIN(angle_for_col(col)) * step_size, CASE WHEN EXISTS ( SELECT 1 FROM map_cells WHERE map_cells.x FLOOR(x COS(angle_for_col(col)) * step_size) AND map_cells.y FLOOR(y SIN(angle_for_col(col)) * step_size) AND map_cells.wall_type ! 0 ) THEN 1 ELSE 0 END FROM ray_steps WHERE hit 0 AND step max_steps ) SELECT col, MIN(step) as hit_step FROM ray_steps WHERE hit 1 GROUP BY col;这个查询对每一列独立计算射线步进直到撞墙。MIN(step)给出撞墙距离然后根据距离计算墙壁在屏幕上的高度。实测中最大的坑递归 CTE 的默认最大递归深度通常只有 100 或 1000。光线投射可能需要几百步如果地图很大很容易超限。解决办法是在查询前设置SET RECURSIVE_QUERY_MAX_DEPTH 10000;不同数据库语法不同或者把步进拆成多个阶段。3.3 从撞墙距离到像素颜色的转换有了每列的撞墙距离后下一步是计算墙壁高度和纹理坐标。墙壁高度与距离成反比wall_height screen_height / distance然后根据墙壁高度和屏幕中心位置确定该列上哪些像素是墙壁、哪些是天花板、哪些是地板。最后根据纹理坐标从纹理表里查出颜色值。这一步在 SQL 里通常用窗口函数和 CASE WHEN 组合完成。比如SELECT col, pixel_y, CASE WHEN pixel_y (screen_height - wall_height) / 2 THEN ceiling_color WHEN pixel_y (screen_height wall_height) / 2 THEN floor_color ELSE wall_color_at(col, pixel_y, wall_height, texture_id) END AS color FROM screen_pixels CROSS JOIN wall_distances;wall_color_at是一个根据纹理坐标查纹理表的子查询。整个渲染查询的输出是一张(col, pixel_y, color)的表外部程序读取后直接绘制。4. 性能实测SQL 游戏循环到底能跑多快4.1 帧率的天花板在哪里先说结论在普通开发机上SQLDoom 的帧率通常在 1-10 FPS 之间具体取决于地图复杂度、屏幕分辨率和数据库优化程度。这当然不能和原生 C 实现的 60 FPS 比但考虑到每一帧要执行几万次数据库查询和更新这个成绩已经相当惊人了。影响帧率的关键因素因素影响程度优化方向屏幕分辨率极高降低渲染分辨率比如 160x120地图格子数量高只加载玩家附近的格子怪物数量中限制同时活跃的怪物数量索引设计极高确保所有坐标查询走索引数据库类型高内存数据库比磁盘数据库快 10 倍以上查询批处理中合并多个 UPDATE 为单个语句4.2 我踩过的索引坑最开始我用的地图表没有在(x, y)上建索引结果光线投射每帧要全表扫描地图表几百次。地图有几千个格子每帧就是几百万次行扫描帧率直接掉到 0.1 FPS基本上是一帧卡三秒。加上复合主键索引后帧率直接翻了 50 倍。这个教训很直白在 SQL 游戏里索引不是优化选项而是生存必需品。任何在游戏循环中被高频查询的字段都必须有索引。另一个坑是UPDATE语句如果没有走索引会锁全表。在游戏循环里每帧都要更新玩家和怪物位置如果WHERE条件没有索引数据库会锁住整张表导致渲染查询被阻塞。解决办法是确保UPDATE的WHERE条件字段有索引并且尽量用主键更新。4.3 用内存数据库把帧率拉起来磁盘数据库的 I/O 延迟是游戏循环的致命伤。每次查询都要读磁盘哪怕有缓存上下文切换的开销也很大。把数据库换成内存模式后帧率通常能提升 5-10 倍。以 SQLite 为例可以用:memory:模式-- 在连接时指定内存模式 -- 连接字符串file::memory:?cacheshared或者用PRAGMA journal_mode MEMORY;和PRAGMA synchronous OFF;来减少磁盘写入。这些设置会降低数据持久性但对于游戏运行时来说持久性本来就不重要——游戏状态丢了就丢了重新开一局就行。注意内存数据库虽然快但数据量受内存限制。如果地图很大可能需要分块加载只把玩家附近的区域放进内存表。5. 移植过程中的三个硬骨头5.1 浮点数精度问题《毁灭战士》的原版代码大量使用定点数fixed-point运算因为 1993 年的硬件没有浮点协处理器。SQLDoom 如果用浮点数会遇到精度问题不同数据库的浮点实现不同同样的查询在不同数据库上可能产生微小差异累积几百帧后玩家位置就偏了。我的做法是在 SQL 里也用定点数。把所有坐标和角度乘以一个固定倍数比如 65536用整数存储和计算。这样不仅避免了浮点精度问题还能用整数索引加速查询。代价是每次计算后要手动处理溢出和舍入写起来比较啰嗦。-- 定点数示例角度用 0-65535 表示 0-360 度 -- 移动计算 UPDATE players SET x x (speed * COS_FIXED(angle)) / 65536, y y (speed * SIN_FIXED(angle)) / 65536 WHERE id 1;COS_FIXED和SIN_FIXED是预先计算好的三角函数表用JOIN查表代替实时计算。这样既快又准。5.2 怪物 AI 的状态机怎么用 SQL 表达《毁灭战士》的怪物 AI 是一个有限状态机每个怪物有当前状态 idle、chase、attack、pain、die 根据玩家位置和自身血量转换状态。在 C 语言里这是一个switch-case结构。在 SQL 里我用CASE WHEN加UPDATE来实现UPDATE monsters SET state CASE WHEN health 0 THEN die WHEN state idle AND can_see_player(id) THEN chase WHEN state chase AND distance_to_player(id) attack_range THEN attack WHEN state attack AND NOT can_see_player(id) THEN chase WHEN state pain AND pain_timer 0 THEN pain ELSE state END, pain_timer CASE WHEN state pain THEN pain_timer - 1 ELSE pain_timer END WHERE EXISTS (SELECT 1 FROM game_state WHERE current_frame % ai_tick_rate 0);can_see_player是一个子查询用射线检测判断怪物和玩家之间是否有墙壁遮挡。这个查询每帧对每个怪物执行一次如果怪物数量多开销很大。优化方法是降低 AI 更新频率——比如每 3 帧才更新一次 AI中间帧复用上次的决策结果。5.3 输入处理键盘事件怎么变成 SQL 参数游戏需要响应键盘输入。SQLDoom 的做法是外部程序捕获键盘事件把按键状态写入一张input_state表然后游戏循环查询这张表来决定玩家动作。CREATE TABLE input_state ( key_code INT PRIMARY KEY, is_pressed BOOLEAN DEFAULT FALSE, pressed_at_frame INT );每帧开始时外部程序更新input_state表游戏循环读取is_pressed字段决定玩家是否移动、射击、开门。这个设计的巧妙之处在于输入和游戏逻辑完全解耦。你可以用任何方式产生输入——键盘、鼠标、甚至另一个 SQL 查询——只要更新input_state表就行。实测发现输入延迟主要来自外部程序和数据库之间的通信开销。如果用本地 socket 或共享内存延迟可以控制在几毫秒如果用网络连接延迟可能达到几十毫秒游戏体验会明显变差。6. 如果你想自己跑一版环境搭建与实操建议6.1 数据库选型SQLite 还是 PostgreSQLSQLDoom 最初是在 SQLite 上实现的因为 SQLite 轻量、无需安装、支持内存模式。但 SQLite 的递归 CTE 性能一般而且不支持并行查询。如果你追求更高帧率PostgreSQL 是更好的选择——它的查询优化器更聪明支持并行查询递归 CTE 的性能也更好。数据库优点缺点适用场景SQLite零配置、内存模式快、单文件递归 CTE 慢、无并行快速体验、嵌入式PostgreSQL优化器强、支持并行、窗口函数丰富需要安装配置、内存占用大追求性能、学习研究MySQL普及率高、文档多递归 CTE 支持较晚、性能一般已有 MySQL 环境我的建议是先用 SQLite 跑通感受一下基本流程如果觉得帧率可以接受就不用折腾了如果想优化再迁移到 PostgreSQL。6.2 最小可运行版本的搭建步骤如果你想自己搭一个 SQLDoom 的最小版本可以按这个顺序来建表创建map_cells、players、monsters、projectiles、game_state、input_state六张核心表导入地图把一张简单的地图比如 16x16 的网格写入map_cells表实现移动写一个UPDATE players语句根据输入表更新玩家位置实现渲染写一个递归 CTE对每一列计算撞墙距离输出像素颜色表外部循环用 Python 或任何语言写一个循环每帧执行一次游戏逻辑 SQL 和渲染 SQL读取像素表并绘制到窗口加入怪物在monsters表里放几个怪物实现简单的追逐 AI加入射击实现子弹表和碰撞检测每一步都可以独立测试。比如第 3 步完成后你可以手动更新input_state表然后查询players表看位置有没有变化。6.3 调试 SQL 游戏逻辑的笨办法但有效调试 SQL 游戏逻辑比调试普通程序麻烦得多因为你不能打断点也不能单步执行。我的办法是把中间状态物化成表。比如光线投射的中间结果每列的撞墙距离不要直接用在最终查询里而是先INSERT INTO debug_ray_distances然后你可以单独查询这张表看看每列的撞墙距离是否合理。如果某一列的距离是 0 或者异常大说明射线步进逻辑有问题。另一个技巧是用EXPLAIN分析查询计划。如果某个查询没有走索引EXPLAIN会告诉你。在游戏循环里任何全表扫描都是性能杀手必须消灭。EXPLAIN QUERY PLAN SELECT * FROM map_cells WHERE x 10 AND y 20; -- 输出应该显示 SEARCH map_cells USING INDEX sqlite_autoindex_map_cells_1 (x? AND y?) -- 如果显示 SCAN map_cells说明索引没生效7. 这个项目真正有意思的地方SQLDoom 表面上是一个猎奇项目但它揭示了一个更深层的事实我们平时对什么语言适合做什么事的判断很多时候只是习惯而不是逻辑必然。SQL 被认为只适合做数据查询但它的递归 CTE、窗口函数、集合运算能力实际上可以表达任意计算逻辑。游戏逻辑之所以通常用 C 或 C# 写是因为那些语言在性能和开发效率上更适合而不是因为 SQL 做不到。我在实际跑这个项目的过程中最大的收获不是学会了怎么写 SQL 游戏而是重新理解了计算这件事。当你被迫用SELECT和JOIN来表达碰撞检测和状态机时你会对数据流和状态变换有全新的认识。这种认识反过来会影响你写普通业务代码的方式——你会更自然地想到用集合运算代替循环用声明式代替命令式。如果你也想试试我的建议是从一个极简版本开始一张 8x8 的地图一个玩家没有怪物只实现移动和渲染。跑通之后再逐步加功能。不要一上来就想着复刻完整版《毁灭战士》——那会让你在第一个递归 CTE 超限的时候就放弃。最后分享一个我在调试时发现的小技巧把游戏循环的每一帧状态都INSERT到一张frame_history表里这样你可以随时回放任意一帧的状态对比不同帧之间的差异。这个技巧在排查为什么玩家会穿墙或者为什么怪物卡住不动这类问题时特别有用。
返回列表