DOOM FPS

Navigate the maze. Kill enemies. Find the exit.

WASD move • Mouse aim • Click shoot • Right-click scope(2x) • Q/1-6 weapons • E door • G grenade • M minimap

Click canvas to lock mouse • ESC to unlock

🏆 Leaderboard

Loading...

在浏览器里复刻 Doom:Canvas 2D Raycasting 全解析

Recreating Doom in the Browser: A Canvas 2D Raycasting Deep Dive

Haibin • 2026-04-18 • 游戏开发 / 图形学

🎮 Doom 的故事

1993 年,id Software 发布了一款彻底改变游戏行业的作品 ——《Doom》。在那个 CPU 主频只有 66 MHz、没有 GPU 的年代,John Carmack 用纯软件渲染实现了流畅的"伪 3D"画面,让全世界的玩家第一次体验到了第一人称射击(FPS)的魅力。

Doom 并不是真正的 3D 游戏。它的世界本质上是一张 2D 网格地图,通过一种叫做 Raycasting(光线投射)的算法,把 2D 的地图信息"投影"成看起来像 3D 的画面。这种技术后来被称为 2.5D —— 介于 2D 和 3D 之间。

有一天我在博客里做完了贪吃蛇之后,突然想:能不能在浏览器里用 Canvas 2D 也复刻一个 Doom?于是就有了这个项目 —— 一个完全用 JavaScript + Canvas 2D 实现的 FPS 游戏,无需 WebGL,无需任何外部资源文件,所有的渲染、音效、物理全部程序化生成。

🔎 Raycasting:从 2D 地图到伪 3D 画面

Raycasting 的核心思想非常优雅:对屏幕上的每一列像素,从玩家位置发射一条射线,找到它碰到的第一面墙,根据距离来决定这面墙在屏幕上画多高。

想象你站在一个由方块组成的迷宫里。你的视野是 60 度,屏幕宽度是 960 像素。那么你就需要发射 960 条射线,均匀分布在这 60 度的扇形范围内:

// 对每一列像素发射射线
for (var col = 0; col < SCREEN_W; col++) {
    // 射线角度 = 玩家朝向 - 半FOV + 当前列的偏移
    var rayAngle = player.angle - HALF_FOV + (col / SCREEN_W) * FOV;
    // ... 用DDA算法找到墙壁
}

DDA 算法

DDA(Digital Differential Analyzer) 是 Raycasting 的核心。它不是沿射线一小步一小步地前进(那样太慢),而是沿网格线跳跃 —— 每一步精确地跳到下一条网格线,检查那个格子是不是墙:

// DDA 核心:在X边界和Y边界之间交替前进
if (sideDistX < sideDistY) {
    sideDistX += deltaDistX;  // 跳到下一条竖直网格线
    mapX += stepX;
    side = 0;  // 碰到的是东/西面的墙
} else {
    sideDistY += deltaDistY;  // 跳到下一条水平网格线
    mapY += stepY;
    side = 1;  // 碰到的是南/北面的墙
}

这个算法的精妙之处在于:每次循环只需要一次比较和一次加法,不需要任何三角函数运算,所以即使在 1993 年的硬件上也能达到 30+ FPS。

鱼眼矫正

如果直接用射线到墙壁的欧几里得距离来计算墙高,你会看到一个诡异的"鱼眼"效果 —— 画面边缘的墙壁看起来向外膨胀弯曲。这是因为屏幕边缘的射线比中心的射线走了更远的路。

修复方法很简单 —— 使用垂直距离而不是实际距离:

// 鱼眼矫正:用垂直距离代替实际距离
// DDA 算法天然给出垂直距离:
var perpDist;
if (side === 0) perpDist = sideDistX - deltaDistX;
else            perpDist = sideDistY - deltaDistY;

// 墙壁高度 = 屏幕高度 / 垂直距离
var wallH = Math.floor(SCREEN_H / perpDist);

距离着色与面着色

为了增强立体感,我们用了两个技巧:

  • 距离衰减:墙壁颜色随距离变暗,shade = max(0.15, 1 - dist / MAX_DEPTH)
  • 面朝向差异:南北面(side=1)的亮度额外乘以 0.7,这样转角处就能看到明暗交替,增强空间感

👤 精灵系统:2D 角色在 3D 空间中

敌人和道具在 Raycasting 引擎中是 Billboard Sprites —— 它们是扁平的 2D 图像,但始终正对玩家(就像广告牌一样)。这样无论你从哪个角度看,它们都"看起来"是 3D 的。

精灵渲染的关键步骤:

  1. 世界坐标→屏幕坐标:计算精灵相对于玩家的角度偏差,映射到屏幕 X 位置
  2. 距离排序:先画远的,再画近的(画家算法)
  3. 深度缓冲裁剪:逐列比较精灵距离和该列墙壁距离,只画精灵比墙近的部分
// 精灵投影:世界坐标 → 屏幕位置
var spriteAngle = Math.atan2(dy, dx) - player.angle;
var screenX = SCREEN_W / 2 * (1 + spriteAngle / HALF_FOV);
var spriteH = SCREEN_H / dist;  // 距离越近,画得越大

// 深度裁剪:逐列检查
for (var col = startX; col < endX; col++) {
    if (dist < depthBuf[col]) {
        // 这一列精灵比墙近,画出来
        drawSpriteColumn(col, ...);
    }
}

有趣的是,本项目的敌人不是用图片贴图,而是用 Canvas 程序化绘制 —— 头、身体、手臂、腿都是用 fillRectarc 拼出来的。这意味着整个游戏没有任何外部资源文件。

🖱 鼠标旋转的坑

FPS 游戏最重要的交互就是鼠标瞄准。在浏览器中,我们使用 Pointer Lock API 来获取鼠标的相对移动量,然后转化为视角旋转:

canvas.requestPointerLock();  // 锁定鼠标

document.addEventListener('mousemove', function(e) {
    if (pointerLocked) {
        mouseMovX += e.movementX;  // 累加水平移动量
    }
});

// 每帧应用旋转
player.angle += mouseMovX * MOUSE_SENS;
mouseMovX = 0;

听起来很简单,但在实际开发中,鼠标视角会突然跳变 —— 转到某个方向时,画面猛地转了 90 度。

这个 bug 困扰了我很久,经历了 4 次修复迭代

尝试方案结果
第 1 次角度归一化到 [0, 2π]❌ 没用
第 2 次per-event clamp ±50px + 帧级 clamp ±150px❌ 依然跳变
第 3 次指数移动平均 (EMA) 平滑❌ 更糟了 —— 操作感变得迟钝粘滞
第 4 次直接丢弃 |movementX| > 200 的异常事件✅ 完美解决!

最终的原因是 浏览器的 Pointer Lock 实现存在 bug:某些帧会报出离谱的 movementX 值(比如突然 +500),这并不是真实的鼠标移动。

// 最终方案:丢弃离群值
document.addEventListener('mousemove', function(e) {
    if (pointerLocked) {
        // 丢弃异常跳变(浏览器 Pointer Lock bug)
        if (Math.abs(e.movementX) < 200) {
            mouseMovX += e.movementX;
        }
    }
});

教训:有时候最简单的方案反而最有效。复杂的平滑算法不仅没解决问题,还引入了新的问题。直接找到异常数据并丢弃才是正解。

💣 手雷物理模拟

手雷的物理系统是整个项目最有趣的部分之一。它模拟了真实的抛物运动和弹跳效果,虽然只有几十行代码,但视觉效果非常满意。

三轴运动

虽然是 2.5D 的游戏,手雷的运动是在 3 个轴上进行的:x/y 是水平位置(地图上的坐标),z 是垂直高度。

// 抛出手雷
grenades.push({
    x: player.x, y: player.y,
    dx: cos * 6, dy: sin * 6,  // 水平速度(朝向方向)
    z: 0.5,                     // 初始高度(手持高度)
    dz: 3.0,                    // 向上抛出的初速度
    life: 3.0                   // 3秒引信
});

// 每帧更新
g.dz -= 9.8 * dt;    // 重力加速度
g.z  += g.dz * dt;   // 更新高度

地面弹跳

当手雷落到地面(z ≤ 0)时,垂直速度反转并乘以衰减系数,模拟非弹性碰撞:

if (g.z <= 0) {
    g.z = 0;
    if (Math.abs(g.dz) > 0.5) {
        g.dz = -g.dz * 0.45;  // 弹跳,保留45%能量
        g.dx *= 0.7;           // 水平速度也衰减
        g.dy *= 0.7;
    } else {
        g.dz = 0;             // 能量不够了,停止弹跳
        g.dx *= 0.92;         // 地面滚动摩擦
        g.dy *= 0.92;
    }
}

墙壁反弹

墙壁碰撞是 X 轴和 Y 轴独立检测 的。这意味着手雷打到墙角时,X 和 Y 方向都会反转,自然地弹回来:

// X方向碰墙?反转X速度
if (MAP[myO][mxN] !== 0) {
    g.dx = -g.dx * 0.5;  // 50%能量保留
    nx = g.x;             // 不穿墙
}
// Y方向碰墙?反转Y速度
if (MAP[myN][mxO] !== 0) {
    g.dy = -g.dy * 0.5;
    ny = g.y;
}

手雷在飞行时还会在地面投射一个椭圆形阴影,帮助玩家判断落点位置。

🤖 敌人 AI 状态机

每个敌人都运行着一个简单的状态机,有 4 个状态:

状态行为转换条件
IDLE原地缓慢巡逻听到枪声或看到玩家 → ALERT
ALERT朝声音方向转身看到玩家 → CHASE
CHASE朝玩家移动进入攻击范围 → ATTACK
ATTACK开火或近战失去视线 → IDLE

"看到玩家"的判定用的是一个简化的 Raycast:沿敌人到玩家的方向,每步 0.3 检查是否碰到墙壁。如果一路畅通,就说明视线没被遮挡。

敌人之间、敌人和玩家之间还有碰撞阻挡,防止它们重叠在一起。这个碰撞半径经历了多次调整 —— 太小会重叠,太大会卡在门口走不过去。

🎵 程序化音效

整个游戏的所有音效都是用 Web Audio API 实时生成的,没有一个外部音频文件。每种武器和音效都是由振荡器(Oscillator)、噪声缓冲(Noise Buffer)、滤波器(BiquadFilter)和增益节点(Gain)组合而成:

音效实现方式
手枪白噪声 + 低通滤波器 800Hz + 快速衰减
霰弹枪白噪声 + 低通 600Hz + 更长的衰减
机枪白噪声 + 带通滤波器 + 极短脉冲
狙击枪锯齿波 150→30Hz + 低通 800Hz(深沉的开裂声)
爆炸长白噪声 + 低通 300Hz + 慢衰减(沉闷的轰鸣)
匕首白噪声 + 高通 3000Hz(尖锐的挥砍声)
脚步白噪声 + 低通 200Hz + 极短脉冲
// 以手枪为例:白噪声 + 低通滤波 + 快速衰减
case 'pistol':
    bufferSize = audioCtx.sampleRate * 0.15;
    buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
    output = buffer.getChannelData(0);
    for (var i = 0; i < bufferSize; i++)
        output[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.1));
    // ... 连接低通滤波器和增益节点

🔒 反作弊设计

排行榜系统需要防止分数作弊。虽然客户端的任何方案都不可能完全防住,但合理的设计可以大幅提高作弊门槛:

  • Token 验证:提交分数时需要附带 md5(score + '_' + duration + '_' + md5(serverSalt)),salt 每天更换
  • 合理性检查:分数 0~99999,时长 0~36000 秒
  • IP 限速:每个 IP 每 5 秒只能提交一次
  • 数据清理:只保留前 20 名,超过 3 周的记录自动删除

一个有趣的小坑:最初 salt 是通过 WordPress 的 esc_js() 传给前端的,但这个函数会把 salt 中的特殊字符转义,导致前后端计算的 token 不一致。解决方案是在传递之前先对 salt 做一次 md5() —— 哈希值只包含 0-9a-f,不会被转义。

🚀 技术栈总结

模块技术
渲染引擎Canvas 2D + Raycasting (DDA 算法)
输入控制Pointer Lock API + 离群值过滤
音效系统Web Audio API 程序化生成
物理模拟欧拉积分 + 弹性碰撞
AI 系统有限状态机 + 视线检测
后端WordPress AJAX + MySQL
反作弊MD5 token + 服务端 salt
外部依赖无(纯原生 JS,无框架、无图片、无音频文件)

整个游戏的代码量约 1800 行 JavaScript,加上 300 行 CSS 和 80 行 PHP,是一个完全自包含的小项目。它证明了即使在 2026 年,Raycasting 这个 1992 年的技术依然有它独特的魅力 —— 简单、高效、而且实现起来非常有趣。

如果你想挑战一下,试试通关两个关卡吧! 😊