滥用 DuckDB-WASM:用 SQL 绘制 3D 图形(某种程度上)
滥用 DuckDB-WASM:用 SQL 绘制 3D 图形(某种程度上)
Sun Apr 20 2025•duckdbsqlwasmdoom Back
在浏览器中构建 SQL 驱动的 Doom 克隆版本
我有一个有点疯狂的想法:我是否可以抛弃大多数传统的 JavaScript 游戏循环和渲染逻辑,构建一个 3D 游戏引擎,让 SQL 查询 来完成繁重的工作? 自然,我决定尝试构建一个原始的、基于文本的 Doom 克隆版本,看看我可以使用 DuckDB-WASM 将其推进到什么程度。
剧透:它 勉强 可以工作,过程通常很痛苦,但我学到了很多关于在浏览器中运行分析数据库引擎的惊人能力(和怪癖),用于它绝对不是为之设计的任务。
设置:SQL 不仅仅用于 SELECT *
忘掉在 JavaScript 对象中管理游戏状态或使用 Canvas/WebGL 绘制像素。我的方法如下:
- 数据库就是世界: 16x16 的地图、玩家坐标(
x
,y
,dir
)、敌人/子弹的位置、游戏设置——所有内容都存在于 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);
- SQL 决定现实:
- 想要前进?
UPDATE player SET x = x + COS(dir)*step, y = y + SIN(dir)*step;
- 子弹击中墙壁?
DELETE FROM bullets WHERE EXISTS (SELECT 1 FROM map WHERE ...)
- 敌人被击杀?
bullets
和enemies
之间的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);
- 渲染器是一个 SQL
VIEW
: 这就是它变得疯狂的地方。我定义了一个名为render_3d_frame
的 SQLVIEW
,它实际上执行光线投射并渲染 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 错误)
- 痛点: 我最初尝试使用标准的 CDN 链接来获取 worker 脚本,但完全失败,并显示
net::ERR_ABORTED 404
。 在浏览器中调试 WASM 加载问题并不总是直观的。 - 解决方案: 深入研究 DuckDB-WASM 文档揭示了更强大的初始化模式:使用他们的帮助函数(
getJsDelivrBundles
)或显式选择一个 bundle(mvp
用于最大兼容性),通过URL.createObjectURL(new Blob(...))
创建 worker,并使用+esm
CDN 端点进行主模块导入。
// 多次错误启动后,我可靠的初始化模式
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
- 痛点: 来自 SQLite/MySQL 的肌肉记忆使我使用
AUTOINCREMENT
作为bullets
表 ID。 DuckDB 迅速给了我一个Parser Error: syntax error at or near "AUTOINCREMENT"
。 - 解决方案: 记住 DuckDB 更接近于标准 SQL 序列。 这意味着
CREATE SEQUENCE my_seq;
然后CREATE TABLE ... (id INTEGER PRIMARY KEY DEFAULT nextval('my_seq'), ...)
。
-- 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
中使用generate_series(0, settings.view_w - 1)
。 绑定器因诸如Table function cannot contain subqueries
甚至Conversion Error: Could not convert string 's.view_w' to INT32
之类的错误而感到恐慌。 - 解决方案: 我不得不大幅重组视图逻辑。 我没有生成所需的确切范围,而是首先生成了一个 固定、超大 的范围(例如 0-255),然后添加了另一个 CTE 层来 过滤 该超大范围,使用来自设置 CTE 的实际
view_w
。
-- 在渲染 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
竞争条件
- 痛点: 我的游戏循环很简单:
setInterval(async () => { await tick(); await render(); }, 150)
。 但是因为tick()
和render()
涉及async
数据库调用,有时一个新的间隔会在前一个间隔完成之前触发。 这在使用临时collisions
表进行子弹命中的情况下最为明显 - 我会得到快速连续的“tablecollisions
does not exist!”,然后是“tablecollisions
already exists!” 错误。 - 解决方案: 一个经典的解决方案:一个简单的布尔锁(
isProcessingTick
)。 现在,间隔回调会检查此标志; 如果为 true,则立即退出。 如果为 false,它会设置标志,在try...finally
中运行异步工作,并在finally
块中清除标志,确保它始终被释放。
// 带有竞争条件保护的游戏循环
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 缓冲区逻辑)
- 痛点: SQL 视图完美地渲染了墙壁/地板/天花板(好吧,对于文本模式来说很完美)。 但是敌人和子弹只是数据。 绘制它们需要知道 它们是否被墙壁隐藏。
- 解决方案: 一种结合 SQL 和 JavaScript 的混合方法。 我创建了 另一个 SQL 视图 (
column_distances
) 专门用于输出每个屏幕列到最近墙壁的距离:
-- 用于列距离的 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 中的精灵渲染非常快。
这是游戏在运行时的样子。 主视图以基于文本的图形显示 3D 透视,而角落中的较小框显示自上而下的迷你地图。 您可以看到墙壁如何根据距离用不同的字符渲染,从而产生原始的 3D 效果。
移动感觉足够灵敏,并且基于 SQL 的碰撞检测效果很好。 用 SQL
DELETE
语句击倒敌人有一种奇怪的满足感。
将 SQL 推向极限:我学到了什么
这个实验教会了我关于 SQL 和基于浏览器的开发的几个重要教训:
- SQL 对于非传统用例来说非常强大。 它不仅仅用于数据检索。 递归 CTE、窗口函数和聚合函数的结合使复杂的算法成为可能。
- DuckDB-WASM 的性能令人印象深刻。 在浏览器中运行能够以每秒 6-7 次的速度处理复杂递归查询的分析数据库引擎并非易事。
- 语言之间的界限可能会变得模糊。 该项目将 SQL 用于游戏状态和渲染基础,JavaScript 用于编排和精灵处理。 没有一个可以单独完成这项工作。
- 跨语言边界进行调试具有挑战性。 当出现问题时,并不总是清楚问题是在 JavaScript、SQL 还是它们之间的接口中。 我添加了广泛的日志记录来跟踪组件之间的流程。
- 查询计划是一个复杂的艺术。 我必须解决 SQL 计划器如何工作的许多限制,尤其是在表函数评估和 CTE 方面。
我会推荐这种方法吗?
对于生产游戏? 绝对不。 这是一个有趣的 hack,但有更好的游戏开发工具。 但是作为一种学习练习? 100% 是的。 该项目迫使我深入思考:
- SQL 查询优化和执行计划
- 光线投射算法和 3D 投影
- 异步 JavaScript 模式
- 浏览器中 WASM 的功能和局限性
亲自尝试
如果您想亲自尝试这种 SQL 驱动的怪物,我已将 完整的源代码放在 GitHub 上(在新标签页中打开)。 总共约 500 行代码,其中大约一半是 SQL,一半是 JavaScript。 我很想看看其他人可以将这个概念推向多远。 你能添加纹理吗? 实现一个更复杂的游戏世界? 添加会移动和反击的敌人? SQL 的兔子洞很深!
接下来是什么?
这个实验让我怀疑 DuckDB-WASM 在浏览器中可能存在哪些其他非常规用途。 物理模拟? 寻路算法? 全文搜索引擎? 有时,最有趣的项目来自以它们从未打算使用的方式使用工具。 您希望接下来看到什么奇怪的 DuckDB-WASM 实验? 不是由 🤖 制作的