首页 > AI前沿 > DOOM, simulated and rendered inside the Firebird SQL database using WASM

DOOM, simulated and rendered inside the Firebird SQL database using WASM

Hacker News 2026-10-06 16:34 5 阅读 查看原文
Firebird DOOM DOOM, simulated and rendered inside the Firebird SQL database, running entirely in your browser on Firebird 6 compiled to WebAssembly. ▶ Play: https://mariuz.github.io/firebird-doom/ Every game tic is a PSQL procedure call. Every frame is a SELECT. JavaScript only reads the keyboard and paints the rows Firebird returns. These screenshots come from npm run screenshots, which runs the same SQL and rasteriser as the page, headless in Node. This project follows CedarDB's SQL DOOM (the original WAD, rendered by a database) and DOOMQL (a DOOM-like game in pure SQL), and DuckDB-DOOM (a SQL raycaster in the browser through DuckDB-WASM). This one is graphical, plays real DOOM maps, and runs Firebird in the tab. How it works keyboard ─▶ SELECT * FROM doom_tic(tics, fwd, side, turn, fire, use, weapon, run) ← game logic SELECT * FROM frame_walls ← which wall slice is visible in which column SELECT * FROM frame_sprites ← which sprite frame, where, how bright SELECT * FROM frame_sectors ← live floor/ceiling heights and light ─▶ JS looks up texels + colormaps ─▶ The renderer There are two wall renderers. You can switch between them under Renderer in the page's settings; the choice is stored in VIEWCFG.USE_BSP. BSP front to back with solidsegs (the default). RENDER_SLICES_BSP walks NODES from the root like R_RenderBSPNode, nearer child first. Before entering the farther child it projects that child's bounding box onto the screen (R_CheckBBox) and skips the whole subtree if every column it covers is already hidden. In each subsector it projects the segs that face the viewer (R_AddLine). When a seg is solid (one-sided, or a closed door) its columns are marked as covered (R_ClipSolidWallSegment). PSQL has no arrays, so DOOM's solidsegs list is a VARCHAR with one character per screen column, and the traversal stack is a string too. The walk stops when no '0' is left in the coverage string. On Freedoom's 36 maps this is about 3× faster than brute force, and it finds exactly the same visible walls. CI checks that. Brute force. RENDER_SLICES projects every linedef in front of the camera, whether or not anything hides it. Both generators transform into view space, clip to the near plane, and intersect each screen column's ray with the seg. That gives an exact depth, a texture column, and the vertical opening the seg leaves (open_top/open_bot) for whatever is behind it. RENDER_WALLS then plays the part of DOOM's ceilingclip[]/floorclip[] arrays: ORDER BY col, depth sorts each column front to back, and the clip window is carried down the column. The same idea, stated declaratively, is the FRAME_WALLS_WINDOWED view: SELECT * FROM (SELECT s.*, COALESCE(MAX(open_top) OVER (PARTITION BY col ORDER BY depth ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), 0) clip_top, COALESCE(MIN(open_bot) OVER (PARTITION BY col ORDER BY depth ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), 1e9) clip_bot FROM render_slices s) WHERE clip_top < clip_bot The smoke test checks that FRAME_WALLS_WINDOWED matches FRAME_WALLS slice for slice. The game uses the procedural clip because Firebird's window sort costs about twice as much. Two Firebird-specific performance lessons: Derived tables are inlined. Each reference to a computed CTE column re-evaluates its whole expression tree, so a five-deep chain of projections took seconds. Generator procedures compute each value once into a variable. A PSQL loop runs at about 0.2 µs per statement in WASM. Pin the join order. Joining a computed range to SCREEN_COLS took 65 s as an inner join, because the optimizer drove from the wrong side. With CROSS JOIN LATERAL or LEFT JOIN it took 0.2 s. Floors and ceilings: visplanes DOOM doesn't texture floors column by column. While drawing walls it records, for each column, which rows of the front sector's ceiling and floor the wall leaves visible (markceiling and markfloor in R_StoreWallRange). It collects those rows into visplanes: one per distinct height, flat and light level, with at most one span per column. Then it draws each visplane as horizontal spans, because every pixel in a row of a flat surface is the same distance away. Here RENDER_WALLS returns those rows with every slice: c_top/c_bot for the ceiling and f_top/f_bot for the floor, computed from the same clip window it uses for the walls. The browser does R_FindPlane/R_CheckPlane. It groups the spans by (height, flat, light), starts a new plane when a column is already taken, and merges all sky into one plane. R_MakeSpans then sweeps each plane left to right, turning column spans into row spans. R_MapPlane draws each row span with one distance and light lookup, stepping the texture coordinates linearly. SELECT * FROM frame_visplanes shows the current frame's planes in the SQL console, and the stats line under the view counts them. Across Freedoom's maps the busiest frame needs 42, comfortably under vanilla DOOM's MAXVISPLANES of 128. Running locally npm install npm run fetch-wad npm test npm run serve npm run fetch-wad downloads Freedoom and writes public/wads/freedoom1.wad, minus sound and music. npm test runs the game SQL in the real WASM engine under Node. npm run serve builds dist/ and serves it on http://localhost:8080 without COOP/COEP headers, just like GitHub Pages. That way the service worker is what makes the page cross-origin isolated (Firebird's pthreads need SharedArrayBuffer). You can also load your own DOOM1.WAD / DOOM.WAD / DOOM2.WAD with the file picker. Nothing is uploaded anywhere. npm run test:all-maps loads, plays and renders every map in the WAD, and fires each map's first teleporter. npm run test:renderers compares the BSP and brute-force renderers from several spots and headings on every map, and reports the speed-up. npm run screenshots regenerates docs/*.png. node scripts/bench.mjs queries.sql times SQL statements against a loaded map, with statements separated by -- @@ lines. Controls Click the view to capture the mouse. WASD or the arrow keys move, Ctrl or a click fires, Space/E uses, Shift runs, 1–4 pick weapons, Tab shows the automap, and P pauses. Under the view you can set Detail (320 or 160 columns) and Renderer (BSP + solidsegs, or brute force). Both settings are remembered in your browser. The SQL console under the game queries the live game database. Try the IDKFA button. Deploying .github/workflows/pages.yml runs on every push to main. It installs, fetches and caches Freedoom, runs the SQL smoke test and the BSP-vs-brute-force renderer check, builds, and publishes dist/ to GitHub Pages. Pull requests run everything except the deploy. Simplifications Monster movement, attack timing and accuracy follow DOOM's rules, not its exact frame tables. Projectiles fly flat. There's no sound, no rocket launcher, plasma or BFG, and no crushers. Large maps with many monsters awake at once can still drop below 10 fps. The Low detail setting (160 columns, like DOOM's own) halves the render cost. Credits Engine: Electric Firebird (firebird-wasm, Apache-2.0) Game data: Freedoom (BSD-3-Clause) DOOM © id Software. This is a clean-room SQL re-implementation that reads the WAD format. Inspired by SQL DOOM / DOOMQL by CedarDB and duckdb-doom MIT licensed.