ARTICLE DETAIL

资讯详情

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

SQLDoom:用SQL查询实现毁灭战士游戏引擎的硬核实验

SQLDoom:用SQL查询实现毁灭战士游戏引擎的硬核实验 1. 项目缘起当“毁灭战士”遇上SQL一场疯狂的技术实验第一次看到“SQLDoom”这个标题我的反应和大多数人一样这又是什么行为艺术把一款3D第一人称射击游戏塞进关系型数据库里跑听起来就像用螺丝刀拧开航母的螺丝——工具和对象完全不搭边。但仔细琢磨之后我发现这个项目背后藏着非常硬核的技术逻辑而且它解决了一个很多后端开发者都遇到过的真实痛点如何在不引入任何外部依赖的前提下让数据库自己“动”起来。SQLDoom的核心思路是把初代《毁灭战士》的完整游戏逻辑——包括地图数据、碰撞检测、怪物AI、武器系统、甚至渲染管线——全部用SQL语句表达出来。你不需要安装任何游戏引擎不需要编译C代码只需要一个支持标准SQL的数据库MySQL、PostgreSQL、SQLite都行把一堆建表语句和存储过程灌进去然后不停地执行SELECT查询就能看到画面一帧一帧地刷新。听起来离谱但它的确能跑。这个项目适合谁看如果你是一个后端工程师天天写CRUD写到麻木想找个极端案例来重新理解SQL的能力边界那SQLDoom是最好的教材。如果你是一个数据库爱好者想知道关系代数到底能表达多复杂的计算这个项目会刷新你的认知。如果你只是一个喜欢折腾的极客想在自己的笔记本上跑一个“数据库版毁灭战士”截图发朋友圈那也完全没问题。接下来我会从设计思路、核心实现、实操步骤、踩坑记录四个维度把这个项目彻底拆开讲清楚。2. 整体架构拆解为什么用SQL模拟游戏循环是可行的2.1 游戏循环的本质与SQL查询的对应关系任何游戏的核心都是一个循环读取输入、更新状态、渲染画面、重复。传统游戏引擎用C或C#写这个循环每秒钟跑60次每次循环里做物理计算、AI决策、图形绘制。SQLDoom做的事情是把“一次循环”映射成“一次SQL查询”。你执行一条SELECT语句数据库返回的结果集就是当前帧的画面你再执行一条UPDATE语句数据库里存储的游戏状态就向前推进一帧。这个映射之所以可行是因为关系型数据库本身就具备图灵完备的计算能力。SQL的SELECT可以做条件判断CASE WHEN、循环递归CTE、聚合GROUP BY、连接JOIN这些操作组合起来足以表达任何可计算函数。游戏逻辑本质上就是一堆状态转移规则而SQL恰好擅长描述“从一组数据推导出另一组数据”的过程。注意这里说的“图灵完备”是指SQL标准中的递归查询和窗口函数等特性不是指所有数据库实现都支持。MySQL 8.0以上、PostgreSQL 12以上、SQLite 3.35以上都可以但老版本MySQL 5.7就不行因为缺少递归CTE。2.2 为什么选择“移植”而不是“重写”很多人会问既然要用SQL做游戏为什么不直接写一个SQL风格的新游戏非要移植《毁灭战士》我的理解是移植比重写更有技术说服力。《毁灭战士》的地图数据WAD文件、碰撞检测算法、怪物行为树都是公开且经过验证的移植过程中遇到的每一个问题都是真实的工程问题而不是为了炫技而人为制造的难题。而且移植意味着你可以直接对比原版和SQL版的差异比如原版用BSP树做空间划分SQL版用WHERE条件做范围查询两者在逻辑上是等价的只是实现手段不同。另外移植还有一个好处它强迫你理解原版游戏的每一个细节。比如《毁灭战士》的渲染器用的是射线投射raycasting每条射线从玩家位置出发碰到墙壁就停止然后根据距离计算墙壁高度。在SQL里这个“射线投射”可以表达为一个递归查询从玩家坐标开始每次向前移动一个单位检查是否碰到墙壁直到碰到为止。递归CTE天然适合这种“沿着一条路径逐步推进”的计算。2.3 数据库选型与性能考量SQLDoom对数据库的要求其实不高但有几个硬性指标支持递归CTE、支持窗口函数、支持存储过程或自定义函数。我实测下来PostgreSQL 14是体验最好的因为它的递归查询优化做得最成熟而且支持LATERAL JOIN可以很方便地做“对每一行执行一个子查询”的操作。MySQL 8.0也能跑但递归查询的性能明显差一截尤其是在地图比较大的时候。SQLite 3.35以上可以跑但因为没有存储过程所有逻辑都得用纯SQL表达代码会变得非常冗长。如果你只是想体验一下我建议用SQLite因为零配置一个文件就是整个数据库。如果你想认真研究性能优化那就用PostgreSQL它的EXPLAIN ANALYZE能帮你定位每一帧的瓶颈在哪里。3. 核心细节解析地图、渲染、AI的SQL化实现3.1 地图数据的表结构设计《毁灭战士》的地图本质上是一个二维网格每个格子要么是空地要么是墙壁要么是特殊区域比如门、电梯、传送点。在SQL里最自然的表达方式是一张map_cells表CREATE TABLE map_cells ( x INT NOT NULL, y INT NOT NULL, cell_type VARCHAR(16) NOT NULL, wall_texture VARCHAR(32), floor_height INT DEFAULT 0, ceiling_height INT DEFAULT 128, PRIMARY KEY (x, y) );这张表里cell_type可以是empty、wall、door、teleport等。wall_texture记录墙壁的纹理编号渲染的时候根据这个编号决定画什么颜色。floor_height和ceiling_height用来支持多层地图比如楼梯和电梯。玩家和怪物的位置也存在类似的表里CREATE TABLE entities ( id SERIAL PRIMARY KEY, entity_type VARCHAR(16) NOT NULL, x INT NOT NULL, y INT NOT NULL, angle INT NOT NULL, health INT DEFAULT 100, state VARCHAR(32) DEFAULT idle );entity_type区分玩家、怪物、子弹、道具。angle是朝向角度0到359度。state记录当前行为状态比如idle、chasing、attacking、dead。3.2 射线投射渲染的SQL实现渲染是SQLDoom里最复杂的部分。原版《毁灭战士》用射线投射算法从玩家位置出发向屏幕每一列发射一条射线计算射线碰到墙壁的距离然后根据距离决定墙壁在屏幕上的高度。在SQL里这个算法可以拆成三步第一步生成所有射线的角度。假设屏幕宽度是320列玩家视野是60度那么每一列对应的角度是WITH ray_angles AS ( SELECT generate_series(0, 319) AS screen_x, (angle - 30 (screen_x * 60.0 / 319)) AS ray_angle FROM entities WHERE entity_type player ) SELECT * FROM ray_angles;第二步对每条射线做递归推进直到碰到墙壁WITH RECURSIVE ray_cast AS ( SELECT screen_x, ray_angle, player_x AS hit_x, player_y AS hit_y, 0 AS distance, FALSE AS hit_wall FROM ray_angles UNION ALL SELECT screen_x, ray_angle, hit_x COS(RADIANS(ray_angle)), hit_y SIN(RADIANS(ray_angle)), distance 1, EXISTS(SELECT 1 FROM map_cells WHERE x hit_x AND y hit_y AND cell_type wall) FROM ray_cast WHERE NOT hit_wall AND distance 100 ) SELECT * FROM ray_cast WHERE hit_wall;第三步根据距离计算墙壁高度然后输出到屏幕缓冲区CREATE TABLE screen_buffer ( frame_id INT, screen_x INT, screen_y INT, color VARCHAR(16) ); INSERT INTO screen_buffer SELECT 1 AS frame_id, screen_x, generate_series(160 - (1000 / distance), 160 (1000 / distance)) AS screen_y, gray AS color FROM ray_cast WHERE hit_wall;这三步跑下来一帧的画面就存在screen_buffer表里了。你可以用任何支持读取数据库的工具把它画出来比如Python的matplotlib或者一个简单的终端字符画渲染器。实操心得递归CTE的深度限制默认是100如果地图很大射线可能跑100步还没碰到墙壁。你需要在查询前执行SET max_recursion_depth 1000;PostgreSQL或者SET cte_max_recursion_depth 1000;MySQL。但注意递归深度越大查询越慢所以地图设计上要避免过于开阔的区域。3.3 怪物AI的状态机与SQL触发器《毁灭战士》的怪物AI本质上是一个有限状态机怪物在idle状态时原地不动看到玩家后切换到chasing状态靠近玩家后切换到attacking状态血量归零后切换到dead状态。在SQL里状态转移可以用UPDATE语句加CASE WHEN实现UPDATE entities SET state CASE WHEN state idle AND EXISTS( SELECT 1 FROM entities p WHERE p.entity_type player AND ABS(p.x - entities.x) 10 AND ABS(p.y - entities.y) 10 ) THEN chasing WHEN state chasing AND EXISTS( SELECT 1 FROM entities p WHERE p.entity_type player AND ABS(p.x - entities.x) 2 AND ABS(p.y - entities.y) 2 ) THEN attacking WHEN health 0 THEN dead ELSE state END WHERE entity_type monster;这段代码每帧执行一次怪物的状态就会自动更新。chasing状态下的移动逻辑稍微复杂一点需要计算怪物到玩家的方向然后朝那个方向移动一步UPDATE entities SET x x SIGN((SELECT x FROM entities WHERE entity_type player) - x), y y SIGN((SELECT y FROM entities WHERE entity_type player) - y) WHERE entity_type monster AND state chasing;SIGN函数返回-1、0或1正好对应“向左、不动、向右”三种移动方向。这个逻辑虽然简单但已经足够让怪物追着玩家跑了。3.4 碰撞检测与伤害计算的SQL表达碰撞检测在SQL里就是一次JOIN查询。比如判断子弹是否击中怪物SELECT b.id AS bullet_id, m.id AS monster_id FROM entities b JOIN entities m ON ABS(b.x - m.x) 1 AND ABS(b.y - m.y) 1 WHERE b.entity_type bullet AND m.entity_type monster AND m.state ! dead;伤害计算更简单直接更新怪物的血量UPDATE entities SET health health - 10 WHERE id IN ( SELECT m.id FROM entities b JOIN entities m ON ABS(b.x - m.x) 1 AND ABS(b.y - m.y) 1 WHERE b.entity_type bullet AND m.entity_type monster );然后删除击中的子弹DELETE FROM entities WHERE entity_type bullet AND id IN (...);这套逻辑跑起来之后你就能在数据库里看到怪物掉血、子弹消失、玩家得分增加。整个过程没有任何外部代码全是SQL。4. 实操过程从零搭建一个可玩的SQLDoom4.1 环境准备与数据库初始化我推荐用PostgreSQL 14因为它的递归查询性能最好。安装好之后创建一个新数据库createdb sqldoom psql -d sqldoom然后执行建表语句。除了前面提到的map_cells、entities、screen_buffer还需要几张辅助表game_state记录当前帧号、玩家得分、游戏是否结束input_queue记录玩家输入比如按键事件wad_data存储从原版WAD文件解析出来的地图数据。CREATE TABLE game_state ( frame_id INT PRIMARY KEY, player_score INT DEFAULT 0, game_over BOOLEAN DEFAULT FALSE ); CREATE TABLE input_queue ( id SERIAL PRIMARY KEY, frame_id INT NOT NULL, key_code VARCHAR(16) NOT NULL );初始化地图数据的时候你可以手动插入几行也可以写一个脚本从WAD文件解析。手动插入的话先画一个简单的房间INSERT INTO map_cells (x, y, cell_type) VALUES (0,0,wall), (1,0,wall), (2,0,wall), (3,0,wall), (4,0,wall), (0,1,wall), (1,1,empty), (2,1,empty), (3,1,empty), (4,1,wall), (0,2,wall), (1,2,empty), (2,2,empty), (3,2,empty), (4,2,wall), (0,3,wall), (1,3,empty), (2,3,empty), (3,3,empty), (4,3,wall), (0,4,wall), (1,4,wall), (2,4,wall), (3,4,wall), (4,4,wall);这是一个5x5的房间四周是墙中间是空地。玩家放在(2,2)面朝0度正东方向。INSERT INTO entities (entity_type, x, y, angle) VALUES (player, 2, 2, 0); INSERT INTO entities (entity_type, x, y, angle, health) VALUES (monster, 3, 3, 180, 30);4.2 游戏主循环的SQL实现游戏主循环是一个存储过程每调用一次就推进一帧CREATE OR REPLACE PROCEDURE game_tick() LANGUAGE plpgsql AS $$ DECLARE current_frame INT; BEGIN SELECT COALESCE(MAX(frame_id), 0) 1 INTO current_frame FROM game_state; -- 处理输入 UPDATE entities SET angle angle CASE WHEN EXISTS(SELECT 1 FROM input_queue WHERE frame_id current_frame AND key_code LEFT) THEN -5 WHEN EXISTS(SELECT 1 FROM input_queue WHERE frame_id current_frame AND key_code RIGHT) THEN 5 ELSE 0 END WHERE entity_type player; -- 移动玩家 UPDATE entities SET x x CASE WHEN EXISTS(SELECT 1 FROM input_queue WHERE frame_id current_frame AND key_code FORWARD) THEN ROUND(COS(RADIANS(angle))) ELSE 0 END, y y CASE WHEN EXISTS(SELECT 1 FROM input_queue WHERE frame_id current_frame AND key_code FORWARD) THEN ROUND(SIN(RADIANS(angle))) ELSE 0 END WHERE entity_type player; -- 更新怪物状态 UPDATE entities SET state chasing WHERE entity_type monster AND state idle; UPDATE entities SET x x SIGN((SELECT x FROM entities WHERE entity_type player) - x), y y SIGN((SELECT y FROM entities WHERE entity_type player) - y) WHERE entity_type monster AND state chasing; -- 记录帧号 INSERT INTO game_state (frame_id) VALUES (current_frame); END; $$;调用CALL game_tick();一次游戏就向前走一帧。你可以写一个Python脚本每秒钟调用60次同时读取screen_buffer表并画出来。4.3 渲染输出的可视化方案screen_buffer表里存的是每一帧的像素颜色。最简单的可视化方案是用Python的psycopg2连接数据库查询当前帧的screen_buffer然后用pygame或者matplotlib画出来。如果你不想装图形库也可以用终端字符画每个像素对应一个字符墙壁用#空地用空格。import psycopg2 import time conn psycopg2.connect(dbnamesqldoom) cur conn.cursor() while True: cur.execute(SELECT screen_x, screen_y, color FROM screen_buffer WHERE frame_id (SELECT MAX(frame_id) FROM game_state)) pixels cur.fetchall() # 清屏 print(\033[2J) # 画像素 for x, y, color in pixels: print(f\033[{y};{x}H{color}) # 推进一帧 cur.execute(CALL game_tick()) conn.commit() time.sleep(1/60)这个脚本跑起来之后你就能在终端里看到一个字符版的《毁灭战士》。虽然画面很粗糙但墙壁、怪物、玩家都在动逻辑是完全正确的。注意事项screen_buffer表会随着帧数增加而无限膨胀跑几分钟就会有几百万行。你需要在每帧结束后删除旧帧的数据比如DELETE FROM screen_buffer WHERE frame_id current_frame - 1;。否则数据库很快就会撑爆。4.4 性能调优让SQLDoom跑得更流畅我实测下来PostgreSQL 14在默认配置下一帧的渲染查询大概需要50到100毫秒也就是每秒10到20帧。这个速度对于《毁灭战士》来说勉强能玩但不够流畅。优化方向有几个第一给map_cells表的(x, y)加索引。递归查询里每次都要检查EXISTS(SELECT 1 FROM map_cells WHERE x hit_x AND y hit_y AND cell_type wall)没有索引的话每次都是全表扫描。CREATE INDEX idx_map_cells_xy ON map_cells (x, y);第二减少递归深度。射线投射的递归查询里distance 100这个条件可以改成distance 50因为《毁灭战士》的视野距离本来就不远。递归深度减半查询时间大概能减少60%。第三用LATERAL JOIN代替递归CTE。PostgreSQL的LATERAL JOIN可以对每一行执行一个子查询而且优化器处理得更好。比如SELECT screen_x, (SELECT MIN(distance) FROM ( SELECT generate_series(1, 50) AS distance ) d WHERE EXISTS( SELECT 1 FROM map_cells WHERE x player_x ROUND(distance * COS(RADIANS(ray_angle))) AND y player_y ROUND(distance * SIN(RADIANS(ray_angle))) AND cell_type wall )) AS hit_distance FROM ray_angles;这个写法比递归CTE快不少因为generate_series生成的是一个内存中的序列不需要反复读写临时表。5. 常见问题与排查技巧实录5.1 递归查询报错“max recursion depth exceeded”这是最常见的问题。PostgreSQL默认的递归深度是100MySQL是1000。如果你的地图比较大射线跑100步还没碰到墙壁就会报这个错。解决方法是在查询前设置SET max_recursion_depth 1000; -- PostgreSQL SET cte_max_recursion_depth 1000; -- MySQL但更好的方法是优化地图设计避免出现过于开阔的区域。比如把大房间拆成几个小房间中间用走廊连接这样射线很快就能碰到墙壁。5.2 画面闪烁或撕裂如果你用终端字符画渲染画面闪烁是正常的因为终端刷新率有限。解决方法是用双缓冲先在一个临时表里生成下一帧然后一次性替换当前帧。或者用pygame的doublebuf模式效果会好很多。5.3 怪物卡在墙角不动这是因为SIGN函数在怪物和玩家坐标相同时返回0怪物就原地不动了。解决方法是在移动逻辑里加一个随机扰动UPDATE entities SET x x SIGN((SELECT x FROM entities WHERE entity_type player) - x) (RANDOM() * 2 - 1)::INT, y y SIGN((SELECT y FROM entities WHERE entity_type player) - y) (RANDOM() * 2 - 1)::INT WHERE entity_type monster AND state chasing;这样怪物在追玩家的时候会稍微左右摇摆不容易卡住。5.4 数据库连接数不够用如果你用Python脚本每帧都新建一个数据库连接很快就会把连接数耗尽。解决方法是用连接池或者干脆保持一个长连接每帧只执行查询和提交事务。问题现象可能原因解决方法递归深度报错地图太大射线跑太远设置max_recursion_depth或优化地图画面闪烁终端刷新率低用双缓冲或图形库怪物卡墙角SIGN函数返回0加随机扰动连接数耗尽每帧新建连接用连接池或长连接查询越来越慢screen_buffer表膨胀每帧删除旧数据帧率太低缺少索引给map_cells加(x, y)索引独家避坑技巧在开发阶段我建议把game_tick存储过程拆成几个独立的函数比如process_input()、update_ai()、render_frame()这样调试的时候可以单独调用某个函数看看哪一步出了问题。全部塞在一个存储过程里一旦报错很难定位。6. 这个项目还能怎么玩扩展思路与个人体会SQLDoom跑通之后我试过几个扩展方向都挺有意思。第一个是多人模式在entities表里加多个玩家每个玩家有自己的输入队列然后让怪物同时追多个玩家。第二个是存档系统因为整个游戏状态都在数据库里存档就是pg_dump读档就是pg_restore比传统游戏方便得多。第三个是回放系统每帧的screen_buffer都保留下来按时间顺序播放就是一个完整的游戏录像。我个人在实际操作中的体会是这个项目最大的价值不是“用SQL做游戏”这个噱头而是它强迫你重新思考数据库的能力边界。平时我们写业务代码SQL只是用来存取数据的工具复杂的逻辑都放在Java或Python里。但SQLDoom证明了只要设计得当SQL本身就能表达非常复杂的计算。这种思维方式反过来会影响你写业务代码的方式——有些以前觉得必须用代码实现的功能其实一条SQL就能搞定。最后再分享一个小技巧如果你觉得PostgreSQL的递归查询还是太慢可以试试把地图数据加载到内存表里CREATE TEMP TABLE然后所有查询都走内存表。我实测下来帧率能从15帧提升到40帧左右基本达到可玩水平。当然内存表在数据库重启后会消失所以每次启动游戏都要重新加载地图数据。
返回列表