SQLDoom:用SQL查询语言实现毁灭战士游戏引擎 1. 项目缘起当游戏引擎遇上数据库查询语言第一次看到 SQLDoom 这个项目的时候我的反应和大多数人一样——这不是在开玩笑吧把初代《毁灭战士》的完整游戏逻辑和渲染器塞进 SQL 里要知道《毁灭战士》可是 1993 年 id Software 用 C 语言写出来的实时 3D 射击游戏它对性能的要求在当时堪称苛刻而 SQL 本质上是一种声明式的数据查询语言设计初衷是操作关系型数据库里的表格数据跟实时渲染、游戏循环这些概念八竿子打不着。但恰恰是这种不可能的组合让 SQLDoom 成了一个极具学习价值的项目。它做的事情简单来说就是用 SQL 语句来实现《毁灭战士》的核心游戏机制包括地图数据的存储与查询、玩家移动的碰撞检测、敌人 AI 的状态转换、以及基于射线投射的伪 3D 渲染。整个游戏跑在一个数据库引擎之上每一帧的画面更新本质上都是一次或多次 SQL 查询的结果。这个项目适合谁来研究我认为有三类人特别值得花时间琢磨第一类是对数据库原理感兴趣但觉得 SQL 只是增删改查的开发者SQLDoom 会彻底刷新你对 SQL 表达能力的认知第二类是对游戏引擎底层机制好奇的人通过 SQL 这种笨拙的媒介来理解渲染和碰撞检测反而比直接看 C 代码更容易抓住本质第三类是对极限编程、约束驱动开发感兴趣的工程师SQLDoom 就是一个在极端约束下完成复杂系统的经典案例。我花了大概两周时间把这个项目的核心逻辑拆解了一遍下面把我理解到的设计思路、关键技术点、实操细节和踩坑经验完整分享出来。不管你是做后端的、做游戏的还是做数据的相信都能从中拿到一些能用到自己项目里的东西。2. 整体架构设计为什么用 SQL 做游戏引擎2.1 核心设计思路与方案选型SQLDoom 最核心的设计决策就是把游戏状态全部存在关系型数据库的表中然后用 SQL 查询来驱动游戏逻辑。这个决策背后有一套非常清晰的逻辑链条。首先游戏状态天然适合用表格来表示。玩家有位置坐标、朝向角度、生命值、弹药数量这些就是一行记录里的若干列。地图上的每个格子有墙壁类型、地板高度、天花板高度这也是一张表。敌人有类型、位置、状态、血量同样是一张表。当你把游戏世界拆解成这些实体之后会发现关系模型其实非常自然地描述了它们之间的关系。其次游戏逻辑中的很多操作本质上就是数据查询和更新。碰撞检测是什么就是查询玩家目标位置对应的地图格子看它是不是墙壁。敌人 AI 是什么就是根据当前状态和玩家位置查询下一步应该切换到什么状态。渲染是什么就是从玩家位置出发沿着视线方向查询地图数据计算出每个屏幕列应该画什么。这里有一个关键的设计取舍SQLDoom 并没有试图用 SQL 去模拟一个完整的游戏循环而是把游戏循环放在宿主程序比如 Python 或 C 程序里每一帧调用一组 SQL 语句来完成状态更新和画面计算。SQL 负责的是逻辑和数据宿主程序负责的是调度和显示。这个取舍非常重要。如果试图把整个游戏循环也塞进 SQL那就需要数据库支持某种形式的循环控制结构而标准 SQL 并没有这个能力存储过程可以但会引入更多复杂性。把调度层放在外面SQL 只负责单帧的状态计算这样既发挥了 SQL 在数据处理上的优势又避免了它的短板。2.2 数据库表结构设计整个游戏的数据模型围绕几张核心表展开。我按照自己的理解重新梳理了一下大致是这样的结构地图表map_cells存储地图上每个格子的信息。关键列包括x、y格子坐标、wall_type墙壁类型0 表示空地非 0 表示不同材质的墙、floor_height、ceiling_height地板和天花板高度用于实现不同高度的空间、sector_id所属区域 ID用于光照和音效分组。玩家表player只有一行记录存储玩家的x、y坐标angle朝向角度health生命值ammo弹药数量current_weapon当前武器等。敌人表enemies每一行是一个敌人实例包含id、type、x、y、angle、state状态机当前状态、health、target_x、target_y等。游戏状态表game_state存储全局状态比如当前帧号、游戏是否结束、当前关卡 ID 等。渲染缓冲表render_buffer这是渲染器的核心。每一帧渲染逻辑会往这张表里写入每个屏幕列的绘制信息包括column_index屏幕列号、distance距离、wall_type墙面材质、texture_offset纹理偏移等。宿主程序读取这张表把结果画到屏幕上。这种表结构设计的好处是所有的游戏逻辑都可以用标准的 SQL 语句来表达。比如玩家移动的碰撞检测就是一条SELECT语句查询目标位置的地图格子敌人状态转换就是一条UPDATE语句根据条件修改状态列。2.3 渲染器的 SQL 实现原理《毁灭战士》的渲染器用的是射线投射raycasting技术。简单来说对于屏幕上的每一列像素从玩家位置发出一条射线沿着射线方向步进直到碰到墙壁为止。碰到的距离决定了这一列墙的高度碰到的墙面类型决定了用什么纹理。用 SQL 来实现射线投射核心思路是把步进这个过程转化成一次递归查询或者一组预计算的查询。标准 SQL 没有循环但可以用递归 CTECommon Table Expression来实现迭代。比如下面这个简化的射线步进查询WITH RECURSIVE ray_steps AS ( SELECT column_index, player_x AS ray_x, player_y AS ray_y, dir_x AS step_x, dir_y AS step_y, 0 AS step_count FROM rays WHERE column_index 0 UNION ALL SELECT column_index, ray_x step_x, ray_y step_y, step_x, step_y, step_count 1 FROM ray_steps WHERE step_count 100 AND NOT EXISTS ( SELECT 1 FROM map_cells WHERE x FLOOR(ray_x step_x) AND y FLOOR(ray_y step_y) AND wall_type 0 ) ) SELECT * FROM ray_steps WHERE step_count (SELECT MAX(step_count) FROM ray_steps);这段查询的逻辑是从玩家位置出发每次沿射线方向前进一步直到碰到墙壁或者达到最大步数。递归 CTE 在这里充当了循环的角色。实际项目中为了性能通常会一次性为所有屏幕列计算射线而不是逐列处理。这里有一个性能上的关键点递归 CTE 在大多数数据库里都是逐行迭代的如果屏幕宽度是 320 列每列平均步进 50 次那就是 16000 次迭代。这在现代数据库上跑一帧可能需要几十毫秒甚至更久所以 SQLDoom 通常会降低分辨率或者优化步进算法来保证可玩性。2.4 为什么这个项目值得研究从工程角度看SQLDoom 展示了一种用错误工具做正确事情的极端案例。它强迫你思考什么是游戏引擎的本质什么是渲染的本质当你不能用循环、不能用指针、不能用 GPU 的时候你还能不能做出一个游戏从学习角度看这个项目是理解关系代数、递归查询、查询优化、状态机建模的绝佳素材。很多开发者对 SQL 的理解停留在 CRUD 层面SQLDoom 会让你看到 SQL 作为一门计算语言的完整表达能力。从娱乐角度看它本身就是一个很酷的玩具。当你看到自己写的 SQL 语句在屏幕上画出一个能走能打的《毁灭战士》时那种成就感是写普通业务代码给不了的。3. 核心细节解析从地图数据到碰撞检测3.1 地图数据的存储与查询优化《毁灭战士》的地图数据原本是 WAD 文件格式里面包含了顶点、线段、区域sector、侧边sidedef等复杂结构。SQLDoom 为了简化通常会把地图转换成网格模型——每个格子要么是空地要么是墙壁。这种简化牺牲了一些原版地图的细节比如斜墙、不同高度的地板但换来了查询上的极大便利。地图表的核心索引是(x, y)上的唯一索引。这个索引至关重要因为碰撞检测和射线投射都会频繁地根据坐标查询地图格子。如果没有这个索引每次查询都要全表扫描性能会差好几个数量级。CREATE TABLE map_cells ( x INTEGER NOT NULL, y INTEGER NOT NULL, wall_type INTEGER NOT NULL DEFAULT 0, floor_height INTEGER NOT NULL DEFAULT 0, ceiling_height INTEGER NOT NULL DEFAULT 128, sector_id INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (x, y) ); CREATE INDEX idx_map_wall ON map_cells(wall_type) WHERE wall_type 0;上面这个部分索引partial index只索引墙壁格子因为碰撞检测和射线投射只关心墙壁。在 PostgreSQL 和 SQLite 中部分索引可以显著减小索引体积提升查询速度。实操心得如果你的数据库不支持部分索引可以单独建一张walls表只存墙壁格子然后在地图表上建普通索引。查询的时候先查walls表查不到再查地图表。这种热数据分离的思路在游戏开发中很常见。3.2 玩家移动与碰撞检测的 SQL 实现玩家移动的逻辑是这样的根据当前朝向和移动方向计算出目标位置然后检查目标位置是否可通行。如果可通行就更新玩家位置如果不可通行就尝试沿墙滑动只移动 X 或只移动 Y。用 SQL 来实现可以写成一条UPDATE语句配合WHERE条件UPDATE player SET x CASE WHEN NOT EXISTS ( SELECT 1 FROM map_cells WHERE x FLOOR(player.x :dx) AND y FLOOR(player.y) AND wall_type 0 ) THEN player.x :dx ELSE player.x END, y CASE WHEN NOT EXISTS ( SELECT 1 FROM map_cells WHERE x FLOOR(player.x) AND y FLOOR(player.y :dy) AND wall_type 0 ) THEN player.y :dy ELSE player.y END WHERE id 1;这条语句同时处理了 X 轴和 Y 轴的移动并且实现了沿墙滑动的效果——如果 X 方向被挡住但 Y 方向可以走玩家就会沿着墙滑动。这种写法比在宿主程序里写一堆 if-else 要简洁得多而且利用了数据库的原子性不会出现移动一半的中间状态。注意事项FLOOR函数在这里是必须的因为玩家坐标是浮点数而地图格子是整数坐标。如果不取整查询会永远匹配不到任何格子。另外player.x在CASE表达式中被引用了多次某些数据库可能会重复计算可以用 CTE 先算好目标位置来优化。3.3 敌人 AI 的状态机建模《毁灭战士》的敌人 AI 本质上是一个有限状态机。每个敌人有若干状态待机idle、巡逻patrol、追击chase、攻击attack、受伤pain、死亡death。状态之间的转换由特定条件触发比如看到玩家就切换到追击距离足够近就切换到攻击受到伤害就切换到受伤。用 SQL 来建模状态机最直接的方式是在敌人表里加一个state列然后用UPDATE语句根据条件修改这个列UPDATE enemies SET state CASE WHEN health 0 THEN death WHEN state idle AND can_see_player(id) THEN chase WHEN state chase AND distance_to_player(id) 64 THEN attack WHEN state attack AND attack_cooldown(id) 0 THEN chase ELSE state END WHERE state ! death;这里的can_see_player、distance_to_player、attack_cooldown都是自定义函数或者子查询。在实际项目中为了性能通常会把这些判断展开成内联的子查询避免函数调用的开销。状态转换完成后还需要根据新状态执行相应的动作。比如chase状态下敌人要向玩家移动attack状态下要发射子弹。这些动作同样可以用 SQL 来实现UPDATE enemies SET x x CASE WHEN state chase THEN SIGN(player_x - x) * speed ELSE 0 END, y y CASE WHEN state chase THEN SIGN(player_y - y) * speed ELSE 0 END FROM player WHERE enemies.state chase;实操心得状态机的 SQL 实现有一个容易踩的坑——状态转换和动作执行如果放在同一条语句里可能会出现刚转换到 chase 就立刻移动的情况导致敌人移动过于突兀。更好的做法是分两步先更新状态再根据新状态执行动作。这样虽然多了一次查询但逻辑更清晰也更容易调试。3.4 渲染管线的 SQL 化拆解渲染是 SQLDoom 里最复杂的部分。完整的渲染管线包括射线投射、墙面绘制、地板和天花板绘制、精灵敌人、道具绘制、深度排序。用 SQL 来实现需要把每个阶段都转化成查询。射线投射阶段为每个屏幕列计算射线与墙壁的交点。这个阶段可以用递归 CTE 实现也可以用预计算的查找表来加速。查找表的方式是预先计算好每个角度、每个距离对应的射线步进序列存到一张表里运行时直接查询。这种方式牺牲了存储空间但换来了查询速度。墙面绘制阶段根据射线投射的结果计算每列墙面的高度和纹理坐标写入渲染缓冲表INSERT INTO render_buffer (column_index, distance, wall_type, texture_offset, wall_height) SELECT r.column_index, r.distance, m.wall_type, CAST((r.hit_x r.hit_y) * 64 AS INTEGER) % 64, CAST(SCREEN_HEIGHT * WALL_HEIGHT / r.distance AS INTEGER) FROM ray_results r JOIN map_cells m ON m.x FLOOR(r.hit_x) AND m.y FLOOR(r.hit_y) WHERE r.distance 0;精灵绘制阶段更复杂需要根据敌人位置计算屏幕坐标然后做深度测试如果敌人被墙挡住就不画。深度测试可以用一条JOIN加WHERE来实现INSERT INTO sprite_buffer (column_index, sprite_id, screen_y, scale) SELECT s.column_index, e.id, CAST(SCREEN_HEIGHT / 2 - (e.z - player.z) * SCREEN_HEIGHT / s.distance AS INTEGER), CAST(SPRITE_SIZE * 64 / s.distance AS INTEGER) FROM enemy_screen_positions s JOIN enemies e ON e.id s.enemy_id JOIN render_buffer r ON r.column_index s.column_index WHERE s.distance r.distance;这条语句的关键在最后的WHERE s.distance r.distance——只有当敌人比该列的墙壁更近时才把敌人写入精灵缓冲。这就是最基础的深度测试。注意事项渲染缓冲表在每一帧开始前需要清空TRUNCATE或DELETE否则上一帧的数据会残留。TRUNCATE比DELETE快得多因为它不写事务日志在某些数据库里但要注意TRUNCATE不能回滚如果游戏需要支持回放功能就得用DELETE。4. 实操过程从零搭建一个 SQLDoom 原型4.1 环境准备与数据库选型搭建 SQLDoom 原型第一步是选数据库。我试过 SQLite、PostgreSQL 和 MySQL 三种各有优劣。SQLite 的优势是零配置、单文件、嵌入式非常适合做原型。它的递归 CTE 支持从 3.8.3 版本开始就有了窗口函数从 3.25 版本开始支持。缺点是并发性能差但对于单机游戏来说这不是问题。PostgreSQL 的优势是功能最全递归 CTE、窗口函数、部分索引、JSON 支持都很完善查询优化器也最聪明。缺点是部署稍重对于一个小游戏来说有点杀鸡用牛刀。MySQL 的优势是普及率高很多人都装过。缺点是递归 CTE 从 8.0 版本才开始支持而且默认的cte_max_recursion_depth是 1000对于射线投射来说可能不够需要调大。我最终选了 SQLite因为它的零依赖特性让项目更容易分享和复现。下面是一个最小化的建表脚本PRAGMA journal_mode WAL; PRAGMA synchronous OFF; PRAGMA cache_size 10000; CREATE TABLE map_cells ( x INTEGER NOT NULL, y INTEGER NOT NULL, wall_type INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (x, y) ) WITHOUT ROWID; CREATE TABLE player ( id INTEGER PRIMARY KEY CHECK (id 1), x REAL NOT NULL, y REAL NOT NULL, angle REAL NOT NULL, health INTEGER NOT NULL DEFAULT 100 ); CREATE TABLE enemies ( id INTEGER PRIMARY KEY AUTOINCREMENT, x REAL NOT NULL, y REAL NOT NULL, state TEXT NOT NULL DEFAULT idle, health INTEGER NOT NULL DEFAULT 30 ); CREATE TABLE render_buffer ( column_index INTEGER PRIMARY KEY, distance REAL NOT NULL, wall_type INTEGER NOT NULL, texture_offset INTEGER NOT NULL, wall_height INTEGER NOT NULL );实操心得PRAGMA synchronous OFF会关闭同步写入大幅提升写入速度但代价是断电时可能丢失数据。对于游戏这种丢了就丢了的场景这个取舍是值得的。WITHOUT ROWID对于map_cells这种以主键为查询条件的表也很有效它把数据直接存在 B 树节点里减少了一次间接寻址。4.2 地图数据的导入与预处理《毁灭战士》的原版地图是 WAD 格式直接解析比较复杂。为了快速搭建原型我建议先用一个简单的文本格式来定义地图比如用#表示墙壁.表示空地################ #..............# #..####..####..# #..#..........#.# #..#..####....#.# #.....#..#......# #..####..####..# #..............# ################然后用一个 Python 脚本把这个文本地图转换成 SQL 插入语句def import_map(cursor, map_text): for y, row in enumerate(map_text.strip().split(\n)): for x, char in enumerate(row): wall_type 1 if char # else 0 cursor.execute( INSERT INTO map_cells (x, y, wall_type) VALUES (?, ?, ?), (x, y, wall_type) ) cursor.connection.commit()这个脚本很简单但有一个细节需要注意地图的 Y 轴方向。在文本地图里第一行通常是最上面但在游戏坐标系里Y 轴通常向上增长。所以导入的时候可能需要翻转 Y 轴或者在渲染的时候做转换。我建议在导入时就翻转好这样后续所有逻辑都用统一的坐标系。4.3 游戏主循环的宿主程序实现宿主程序负责调度和显示。我用 Python 加 Pygame 来做核心循环大概是这样import sqlite3 import pygame import math conn sqlite3.connect(doom.db) cursor conn.cursor() pygame.init() screen pygame.display.set_mode((320, 200)) clock pygame.time.Clock() while True: for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() exit() keys pygame.key.get_pressed() dx, dy 0, 0 if keys[pygame.K_w]: dx math.cos(player_angle) * 0.1 dy math.sin(player_angle) * 0.1 if keys[pygame.K_s]: dx -math.cos(player_angle) * 0.1 dy -math.sin(player_angle) * 0.1 cursor.execute( UPDATE player SET x CASE WHEN NOT EXISTS ( SELECT 1 FROM map_cells WHERE x CAST(player.x ? AS INTEGER) AND y CAST(player.y AS INTEGER) AND wall_type 0 ) THEN player.x ? ELSE player.x END, y CASE WHEN NOT EXISTS ( SELECT 1 FROM map_cells WHERE x CAST(player.x AS INTEGER) AND y CAST(player.y ? AS INTEGER) AND wall_type 0 ) THEN player.y ? ELSE player.y END WHERE id 1 , (dx, dx, dy, dy)) cursor.execute(DELETE FROM render_buffer) cursor.execute( WITH RECURSIVE ray(column_index, ray_x, ray_y, step_count) AS ( SELECT 0, p.x, p.y, 0 FROM player p WHERE p.id 1 UNION ALL SELECT column_index 1, ray_x 0.05 * COS(p.angle (column_index - 160) * 0.001), ray_y 0.05 * SIN(p.angle (column_index - 160) * 0.001), step_count 1 FROM ray, player p WHERE column_index 320 AND step_count 200 AND NOT EXISTS ( SELECT 1 FROM map_cells WHERE x CAST(ray_x AS INTEGER) AND y CAST(ray_y AS INTEGER) AND wall_type 0 ) ) INSERT INTO render_buffer SELECT column_index, step_count * 0.05, 1, 0, CAST(200 * 64 / (step_count * 0.05 1) AS INTEGER) FROM ray WHERE step_count 0 GROUP BY column_index HAVING step_count MAX(step_count); ) screen.fill((0, 0, 0)) cursor.execute(SELECT column_index, wall_height FROM render_buffer) for col, height in cursor.fetchall(): top 100 - height // 2 pygame.draw.line(screen, (200, 200, 200), (col, top), (col, top height)) pygame.display.flip() clock.tick(30)这段代码虽然简陋但已经包含了完整的游戏循环输入处理、玩家移动、射线投射、渲染。跑起来之后你就能看到一个能走动的伪 3D 画面。注意事项上面这段代码里的射线投射是逐列递归的性能很差。实际项目中应该改成一次性为所有列计算或者用预计算的查找表。另外CAST(ray_x AS INTEGER)在 SQLite 里是截断取整对于负数会向零取整而FLOOR是向下取整两者在负数上有区别。如果地图坐标可能为负要用FLOOR而不是CAST。4.4 性能调优的实操记录第一版跑起来之后帧率大概只有 5-10 FPS完全没法玩。我做了几轮优化把帧率提到了 30 FPS 以上。第一轮优化是加索引。map_cells表的主键索引是必须的但 SQLite 默认的 B 树索引对于这种小范围查询已经够用了。真正的问题是递归 CTE 里的NOT EXISTS子查询每次迭代都要查一次地图表。我加了一个覆盖索引CREATE INDEX idx_wall_lookup ON map_cells(x, y, wall_type) WHERE wall_type 0;这个索引让NOT EXISTS子查询可以直接从索引里拿到结果不需要回表。第二轮优化是减少递归深度。原来的射线步进是 0.05 单位一步最大 200 步也就是最多 10 个单位的距离。我把步长改成 0.1最大步数改成 100精度略有下降但速度快了一倍。第三轮优化是批量处理。原来的代码是逐列递归320 列就是 320 次递归查询。我改成用一条递归 CTE 同时处理所有列虽然单次查询更复杂但总体开销小了很多。第四轮优化是缓存。地图数据在游戏过程中不会变所以可以在宿主程序里缓存一份到内存碰撞检测和射线投射直接在内存里做只有渲染结果才写回数据库。这个优化最有效直接把帧率提到了 60 FPS 以上。但这样一来SQL 的作用就被削弱了变成了纯粹的数据存储而不是计算引擎。所以这个优化是否采用取决于你的项目目标——如果是为了学习 SQL 的计算能力就不应该用缓存如果是为了做一个能玩的游戏缓存是必须的。5. 常见问题与排查技巧实录5.1 递归查询超出深度限制怎么办这是最常见的问题。不同数据库对递归 CTE 的深度限制不同SQLite 默认是 1000PostgreSQL 默认没有硬限制但受max_stack_depth影响MySQL 默认是 1000。如果你在射线投射时遇到 recursive query aborted 或 maximum recursion depth exceeded 的错误有几个解决方案调大限制。MySQL 可以用SET SESSION cte_max_recursion_depth 10000;SQLite 可以用PRAGMA recursive_triggers ON;配合更大的步数。减小步长。把每步的距离从 0.05 改成 0.1 或 0.2最大步数就降下来了。改用迭代而非递归。在宿主程序里写一个循环每次查询一步虽然查询次数多了但不受递归深度限制。用查找表替代递归。预先计算好所有可能的射线路径存到表里运行时直接查询。实操心得我个人的经验是对于 320x200 的分辨率步长 0.1、最大步数 200 是一个比较好的平衡点。再大的步长会导致画面出现明显的锯齿再小的步长会让递归深度超标。5.2 渲染结果出现撕裂或闪烁这个问题通常是因为渲染缓冲表没有正确清空或者清空和写入之间有时间窗口宿主程序读到了中间状态。解决方案有两个一是用事务把清空和写入包起来保证原子性BEGIN; DELETE FROM render_buffer; INSERT INTO render_buffer ...; COMMIT;二是用双缓冲建两张渲染缓冲表一张用于当前帧的写入一张用于上一帧的读取每帧交换。双缓冲的好处是宿主程序永远读到的是完整的一帧不会出现撕裂。注意事项SQLite 的DELETE在事务里会写日志如果渲染缓冲表很大事务开销会很高。可以用TRUNCATE的等价操作——DELETE FROM render_buffer;在 SQLite 里其实已经很快了因为它只是标记页面为空。如果还是慢可以考虑用DROP TABLE加CREATE TABLE但这样会丢失索引需要重建。5.3 敌人 AI 出现抖动或卡顿敌人 AI 的抖动通常是因为状态转换太频繁。比如敌人在chase和attack之间反复切换导致移动和攻击交替执行看起来就像在抽搐。解决方案是加一个状态锁定机制。当敌人进入某个状态后至少保持 N 帧才能切换。这可以在敌人表里加一个state_locked_until列存储锁定到的帧号UPDATE enemies SET state CASE WHEN health 0 THEN death WHEN state_locked_until (SELECT frame FROM game_state) THEN state WHEN state idle AND can_see_player(id) THEN chase ... END, state_locked_until CASE WHEN state ! (SELECT state FROM enemies WHERE id enemies.id) THEN (SELECT frame FROM game_state) 10 ELSE state_locked_until END WHERE state ! death;这个逻辑有点绕核心思想是如果状态发生了变化就锁定 10 帧如果还在锁定期内就不允许再次变化。5.4 常见问题速查表问题现象可能原因排查方法解决方案帧率极低递归查询太深或缺少索引用EXPLAIN QUERY PLAN查看查询计划加索引、减小步长、用查找表画面撕裂渲染缓冲未原子更新检查是否有事务包裹用事务或双缓冲玩家穿墙碰撞检测的坐标取整方式不对检查CAST和FLOOR的区别统一用FLOOR敌人不动状态机没有正确转换查询敌人表的state列检查状态转换条件射线投射结果异常角度计算错误打印射线方向向量检查三角函数参数数据库文件膨胀渲染缓冲表没有清理检查表大小每帧清空或定期VACUUM实操心得调试 SQLDoom 最好的工具是EXPLAIN QUERY PLANSQLite或EXPLAIN ANALYZEPostgreSQL。它能告诉你查询到底走了索引还是全表扫描是递归 CTE 的哪一步最耗时。我每次遇到性能问题第一件事就是看查询计划。6. 这个项目还能怎么玩SQLDoom 最吸引我的地方不是它做出了一个能玩的游戏而是它打开了一扇门——原来 SQL 还能这么用。顺着这个思路还有很多可以探索的方向。比如你可以把游戏逻辑换成其他类型的游戏。俄罗斯方块、贪吃蛇、推箱子这些游戏的状态都可以用表格来表示逻辑都可以用 SQL 来表达。俄罗斯方块的方块下落就是一条UPDATE语句消行就是一条DELETE语句加一条UPDATE语句。用 SQL 来实现这些游戏比《毁灭战士》简单得多适合作为入门练习。再比如你可以把 SQLDoom 当作一个教学工具。在数据库课程里递归 CTE、窗口函数、查询优化这些概念往往很抽象学生很难理解它们能干什么。SQLDoom 提供了一个具体的、有趣的场景让学生看到这些概念的实际应用。我甚至觉得可以把 SQLDoom 的代码拆解成若干个练习让学生一步步实现射线投射、碰撞检测、状态机在做的过程中理解 SQL 的计算能力。还有一个方向是性能对比。同样的游戏逻辑用 SQL 实现和用 C 实现性能差距有多大在什么规模下 SQL 还能接受什么规模下必须换语言这种对比能帮助你建立对数据库性能边界的直觉。我实测下来在 320x200 分辨率、简单地图、少量敌人的情况下SQLite 版本的 SQLDoom 能跑到 30 FPS 左右已经接近可玩的程度。但如果把分辨率提到 640x400或者把敌人数量增加到几十个帧率就会掉到个位数。这个边界在哪里取决于你的数据库和硬件值得自己动手测一测。最后再分享一个小技巧如果你想让 SQLDoom 跑得更快可以把渲染缓冲表改成内存表SQLite 的CREATE TABLE ... WITHOUT ROWID或者 PostgreSQL 的UNLOGGED TABLE。内存表不写磁盘读写速度极快非常适合这种每帧重建的场景。代价是数据库崩溃时数据会丢失但对于游戏来说丢了就丢了重新开始一局就行。