滥用 DuckDB-WASM:用 SQL 绘制 3D 图形(某种程度上)

Sun Apr 20 2025•duckdbsqlwasmdoom Back

在浏览器中构建 SQL 驱动的 Doom 克隆版本

我有一个有点疯狂的想法:我是否可以抛弃大多数传统的 JavaScript 游戏循环和渲染逻辑,构建一个 3D 游戏引擎,让 SQL 查询 来完成繁重的工作? 自然,我决定尝试构建一个原始的、基于文本的 Doom 克隆版本,看看我可以使用 DuckDB-WASM 将其推进到什么程度。 一个基于文本的 Doom 克隆版本的截图,展示了 3D 视图和迷你地图 剧透:它 勉强 可以工作,过程通常很痛苦,但我学到了很多关于在浏览器中运行分析数据库引擎的惊人能力(和怪癖),用于它绝对不是为之设计的任务。

设置:SQL 不仅仅用于 SELECT *

忘掉在 JavaScript 对象中管理游戏状态或使用 Canvas/WebGL 绘制像素。我的方法如下:

  1. 数据库就是世界: 16x16 的地图、玩家坐标(xydir)、敌人/子弹的位置、游戏设置——所有内容都存在于 DuckDB 表中,就在浏览器选项卡中。
-- MAP: 创建一个四周都是墙的 16x16 世界
CREATETABLEmap(x INT, y INT, tile CHAR);
INSERT INTO map (x,y,tile)
SELECT x,y,'#'FROMrange(0,15) AS t(x) CROSS JOIN (VALUES(0),(15)) AS y(y)
UNION ALL
SELECT x,y,'#'FROM (VALUES(0),(15)) AS x(x) CROSS JOINrange(1,14) AS t(y);
INSERT INTO map (x,y,tile)
SELECT x,y,'.'FROMrange(1,14) AS t1(x) CROSS JOINrange(1,14) AS t2(y);
-- 添加一些内部墙壁
UPDATE map SET tile ='#'WHERE (x,y) IN ((5,5),(6,5),(7,5),(8,5),(5,10),(6,10),(7,10),(8,10));
-- PLAYER: 地图中间的起始位置
CREATETABLEplayer(x DOUBLE, y DOUBLE, dir DOUBLE, icon CHARDEFAULT'@');
INSERT INTO player(x,y,dir) VALUES (8.5,8.5,0);
  1. SQL 决定现实:
    • 想要前进?UPDATE player SET x = x + COS(dir)*step, y = y + SIN(dir)*step;
    • 子弹击中墙壁?DELETE FROM bullets WHERE EXISTS (SELECT 1 FROM map WHERE ...)
    • 敌人被击杀?bulletsenemies 之间的 JOIN,后跟 DELETE 语句。
-- 纯 SQL 中的子弹物理和碰撞
-- 根据所有子弹的方向向量,将其向前移动
UPDATE bullets SET x = x+dx, y = y+dy;
-- 删除击中墙壁的子弹
DELETEFROM bullets b 
WHEREEXISTS (
SELECT1FROM map m 
WHERE m.x =CAST(b.x ASINT) 
AND m.y =CAST(b.y ASINT) 
AND m.tile ='#'
);
-- 为子弹与敌人的碰撞创建一个临时表
CREATE TEMP TABLE collisions AS
SELECT b.id AS bullet_id, e.id AS enemy_id 
FROM bullets b 
JOIN enemies e ONCAST(b.x ASINT) =CAST(e.x ASINT) ANDCAST(b.y ASINT) =CAST(e.y ASINT);
-- 删除被击中的敌人及其子弹
DELETEFROM enemies WHERE id IN (SELECT enemy_id FROM collisions);
DELETEFROM bullets WHERE id IN (SELECT bullet_id FROM collisions);
  1. 渲染器是一个 SQL VIEW 这就是它变得疯狂的地方。我定义了一个名为 render_3d_frame 的 SQL VIEW,它实际上执行光线投射并渲染 3D 场景。 这个野兽使用递归 CTE 为每个屏幕列投射光线,计算墙壁距离(带有鱼眼校正!),确定该列的墙壁切片的高度,然后使用 string_agg 将字符(' '.)拼接在一起,用于最终文本帧的每一行。 这是 SQL 中光线投射算法的核心:
-- 这个递归 CTE 投射光线,直到它们击中墙壁或达到最大距离
raytrace(col, step_count, fx, fy, angle) AS (
-- 从玩家开始的初始光线位置
SELECT r.col, 1, p.x +COS(r.angle)*s.step, p.y +SIN(r.angle)*s.step, r.angle 
FROM rays r, p, s 
UNION ALL
-- 对于每条光线,继续步进,直到我们击中墙壁或达到最大步数
SELECT rt.col, rt.step_count +1, rt.fx +COS(rt.angle)*s.step, 
      rt.fy +SIN(rt.angle)*s.step, rt.angle 
FROM raytrace rt, s 
WHERE rt.step_count < s.max_steps 
ANDNOTEXISTS (
-- 当我们击中墙壁时停止
SELECT1FROM map m 
WHERE m.x =CAST(rt.fx ASINT) 
AND m.y =CAST(rt.fy ASINT) 
AND m.tile ='#'
  )
),
-- 查找每一列的第一次命中
hit_walls AS (
SELECT col, MIN(step_count) as min_steps 
FROM raytrace rt 
WHEREEXISTS (
SELECT1FROM map m 
WHERE m.x =CAST(rt.fx ASINT) 
AND m.y =CAST(rt.fy ASINT) 
AND m.tile ='#'
  ) 
GROUP BY col 
),

是的,SQL 正在计算透视并绘制字符。 对于此类工作,DuckDB 的递归 CTE 功能出乎意料地强大。 4. JavaScript 将它们粘合在一起(并处理精灵): 我的 JS 代码变成了编排者。它处理键盘输入,运行 setInterval 游戏循环,调用 SQL 视图以获取背景帧,然后 获取实体(子弹/敌人)位置和预先计算的墙壁距离(来自_另一个_ SQL 视图!)。 它在 JS 中执行快速的 Z 缓冲区检查,以查看精灵是否比其投影屏幕列中的墙壁更近,如果是,则将其绘制到背景帧上,最后将生成的文本输出到 <pre> 标签上。

// 渲染函数,展示了 Z 缓冲区精灵处理
asyncfunctionrender3d() {
try {
// 并行获取所有渲染数据以提高性能
const [frameResult,distanceResult,bulletResult,enemyResult,playerResult,settingsResult] =
awaitPromise.all([
conn.query(`SELECT y, row FROM render_3d_frame ORDER BY y;`),
conn.query(`SELECT x, dist_corrected FROM column_distances ORDER BY x;`),
conn.query(`SELECT id, x, y FROM bullets;`),
conn.query(`SELECT id, x, y, icon FROM enemies;`),
conn.query(`SELECT x, y, dir FROM player LIMIT 1;`),
conn.query(`SELECT fov, view_w, view_h FROM settings LIMIT 1;`)
  ]);
// 转换为 JS 数组并计算距离查找表
constbackgroundRows=frameResult.toArray().map(r =>r.row);
constwallDistances=distanceResult.toArray().reduce((acc, row) => { 
    acc[row.x] =row.dist_corrected; return acc; 
  }, {});
constbullets=bulletResult.toArray();
constenemies=enemyResult.toArray();
constplayer=playerResult.get(0);
constsettings=settingsResult.get(0);
// 从 SQL 背景创建可变帧缓冲区
constframeBuffer=backgroundRows.map(row =>row.split(''));
// 组合实体以进行渲染
constentities= [ 
...bullets.map(b => ({ ...b, type:'bullet', icon:'*' })),
...enemies.map(e => ({ ...e, type:'enemy' })) 
  ];
// 从后到前排序以获得正确的 Z 顺序
entities.sort((a, b) => (
Math.hypot(b.x -player.x,b.y -player.y) -
Math.hypot(a.x -player.x,a.y -player.y)
  ));
// 3D 投影计算
constcosDir=Math.cos(-player.dir);
constsinDir=Math.sin(-player.dir);
constprojectionFactor=settings.view_w / (2*Math.tan(settings.fov /2));
// 绘制每个带有 Z 缓冲区检查的实体
for (constentityof entities) {
constdx=entity.x -player.x;
constdy=entity.y -player.y;
constdepth= dx * cosDir - dy * sinDir;
if (depth <=0.1) continue; // 在玩家后面或太近
consthorizontalOffset= dx * sinDir + dy * cosDir;
let screen_x =Math.round(settings.view_w /2+
                 (horizontalOffset / depth) * projectionFactor);
if (screen_x <0|| screen_x >=settings.view_w) continue; // 屏幕外
constdrawY=Math.floor(settings.view_h /2);
constfinalY=Math.max(0,Math.min(settings.view_h -1, drawY));
// Z 缓冲区检查:仅当实体比墙壁更近时才绘制
constwallDist= wallDistances[screen_x] !==undefined?
            wallDistances[screen_x] :Infinity;
if (depth < wallDist) {
      frameBuffer[finalY][screen_x] =entity.icon;
    }
  }
// 使用完成的帧更新显示
screenEl.textContent =frameBuffer.map(row =>row.join('')).join("\n");
 } catch (renderError) {
console.error("Error during render3d:", renderError);
 }
}

从本质上讲,我使用了 DuckDB-WASM(专为快速分析而设计)并强迫它充当状态机和基本的渲染管道。

挑战:我与错误、绑定器和浏览器的战斗

这并不是一次顺利的旅程。 这是关于一些更……令人难忘的……挑战以及最终有效的修复的日志:

1. 最初的障碍:DuckDB-WASM 无法加载(404 错误)

// 多次错误启动后,我可靠的初始化模式
import*as duckdb from'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.28.0/+esm';
// 获取可用的 bundles 并选择 MVP (最兼容的)
constJSDELIVR_BUNDLES=duckdb.getJsDelivrBundles();
let bundle =JSDELIVR_BUNDLES['mvp'];
// 使用 Blob URL 从 bundle 创建 worker
constworkerUrl=URL.createObjectURL(
newBlob(
    [`importScripts("${bundle.mainWorker}");`],
    { type:'text/javascript' }
  )
);
constworker=newWorker(workerUrl);
// 创建具有适当错误处理的 DuckDB 实例
constdb=newduckdb.AsyncDuckDB(logger, worker);
awaitdb.instantiate(bundle.mainModule,bundle.pthreadWorker);
URL.revokeObjectURL(workerUrl); // 清理

经验教训:在使用 WASM 库时,始终遵循库作者推荐的初始化模式。

2. SQL 方言陷阱:AUTOINCREMENT vs. SEQUENCE

-- DuckDB 处理自动递增 ID 的方式
DROPSEQUENCEIFEXISTS bullets_id_seq;
CREATESEQUENCEbullets_id_seqSTART1;
CREATETABLEbullets( 
 id INTEGERPRIMARY KEYDEFAULT nextval('bullets_id_seq'), 
 x DOUBLE, y DOUBLE, dx DOUBLE, dy DOUBLE 
);

这突出了关于 DuckDB 的一个重要观点:它不仅仅是浏览器中的 SQLite。 它有自己的 SQL 方言,具有来自 PostgreSQL 和标准 SQL 的细微差别。

3. 与查询计划器作斗争(绑定器错误和表函数)

-- 在渲染 VIEW 定义中
-- 这种方法有效:分离生成和过滤
potential_cols AS ( 
SELECT col FROMgenerate_series(0, 255) AS gs(col) -- 生成 0-255
), 
cols AS ( 
SELECT pc.col FROM potential_cols pc, s WHERE pc.col < s.view_w -- 稍后过滤
),
-- ... 视图的其余部分使用过滤后的 'cols' ...

我最初也忘记了为 generate_series 的输出设置别名,导致 Referenced column "value" not found 错误。 使用 generate_series(...) AS gs(col) 修复。 即使这种方法不太优雅,它也满足了查询计划器。 它教会了我 SQL 查询计划器对如何以及何时可以解析引用有严格的规则,尤其是在使用表生成函数时。

4. 可怕的 async / setInterval 竞争条件

// 带有竞争条件保护的游戏循环
let isProcessingTick =false;
setInterval(async () => {
if (isProcessingTick) return; // 防止重叠
 isProcessingTick =true;
try {
awaittickBullets(); // 异步数据库操作
awaitrender3d();  // 异步数据库操作 + JS
awaitrenderMinimap(); // 更多异步数据库操作
 } catch (intervalError) {
console.error("Error in game loop:", intervalError);
 } finally {
  isProcessingTick =false; // 始终释放锁
 }
},150); // ~6-7 FPS

这是一个经典的提醒,即使用重复事件进行异步计时需要仔细处理,尤其是在涉及数据库操作时。

5. 精灵:超越 SQL 背景(Z 缓冲区逻辑)

-- 用于列距离的 VIEW(用于深度缓冲区)
CREATEVIEWcolumn_distancesAS
WITHRECURSIVE
s AS (SELECT*FROM settings LIMIT1),
p AS (SELECT*FROM player LIMIT1),
potential_cols AS ( SELECT col FROMgenerate_series(0, 255) AS gs(col) ),
cols AS ( SELECT pc.col FROM potential_cols pc, s WHERE pc.col < s.view_w ),
rays AS ( SELECT c.col, (p.dir - s.fov/2.0+ s.fov * (c.col*1.0/ (s.view_w -1))) AS angle 
FROM cols c, s, p ),
raytrace(col, step_count, fx, fy, angle) AS ( /* 与 render_3d_frame 相同 */ ),
hit_walls AS ( /* 与 render_3d_frame 相同 */ )
SELECT
  c.col AS x,
COALESCE(hw.min_steps * s.step *COS(r.angle - p.dir), s.max_steps * s.step) AS dist_corrected
FROM cols c
LEFT JOIN hit_walls hw ON c.col = hw.col
JOIN s ON TRUE
JOIN rays r ON c.col = r.col JOIN player p ON TRUE;

然后,在我的 JavaScript render3d 函数中,我通过比较每个屏幕列的实体深度和墙壁深度来执行 Z 缓冲区检查。

性能和结果

它实际上是如何运行的? 考虑到我们要求 SQL 做什么,它运行得好得出奇。 在一台现代笔记本电脑上,我以 150 毫秒的游戏循环间隔获得约 6-7 FPS。 最昂贵的操作是 SQL 光线投射视图,执行该视图大约需要 80-100 毫秒。 相比之下,JavaScript 中的精灵渲染非常快。 一个展示了玩家移动和射击的游戏玩法的 GIF 这是游戏在运行时的样子。 主视图以基于文本的图形显示 3D 透视,而角落中的较小框显示自上而下的迷你地图。 您可以看到墙壁如何根据距离用不同的字符渲染,从而产生原始的 3D 效果。 移动感觉足够灵敏,并且基于 SQL 的碰撞检测效果很好。 用 SQL DELETE 语句击倒敌人有一种奇怪的满足感。

将 SQL 推向极限:我学到了什么

这个实验教会了我关于 SQL 和基于浏览器的开发的几个重要教训:

  1. SQL 对于非传统用例来说非常强大。 它不仅仅用于数据检索。 递归 CTE、窗口函数和聚合函数的结合使复杂的算法成为可能。
  2. DuckDB-WASM 的性能令人印象深刻。 在浏览器中运行能够以每秒 6-7 次的速度处理复杂递归查询的分析数据库引擎并非易事。
  3. 语言之间的界限可能会变得模糊。 该项目将 SQL 用于游戏状态和渲染基础,JavaScript 用于编排和精灵处理。 没有一个可以单独完成这项工作。
  4. 跨语言边界进行调试具有挑战性。 当出现问题时,并不总是清楚问题是在 JavaScript、SQL 还是它们之间的接口中。 我添加了广泛的日志记录来跟踪组件之间的流程。
  5. 查询计划是一个复杂的艺术。 我必须解决 SQL 计划器如何工作的许多限制,尤其是在表函数评估和 CTE 方面。

我会推荐这种方法吗?

对于生产游戏? 绝对不。 这是一个有趣的 hack,但有更好的游戏开发工具。 但是作为一种学习练习? 100% 是的。 该项目迫使我深入思考:

亲自尝试

如果您想亲自尝试这种 SQL 驱动的怪物,我已将 完整的源代码放在 GitHub 上(在新标签页中打开)。 总共约 500 行代码,其中大约一半是 SQL,一半是 JavaScript。 我很想看看其他人可以将这个概念推向多远。 你能添加纹理吗? 实现一个更复杂的游戏世界? 添加会移动和反击的敌人? SQL 的兔子洞很深!

接下来是什么?

这个实验让我怀疑 DuckDB-WASM 在浏览器中可能存在哪些其他非常规用途。 物理模拟? 寻路算法? 全文搜索引擎? 有时,最有趣的项目来自以它们从未打算使用的方式使用工具。 您希望接下来看到什么奇怪的 DuckDB-WASM 实验? 不是由 🤖 制作的