我用 R3F 手搓了一个能走进去的 3D 迷宫简历(上):从选型架构到迷宫生成算法

把简历做成"能走的迷宫"是什么体验?HR 点进来,WASD 一通乱窜,走到一个发光的光柱前------唰,一段职业经历弹出来。走完全程,你的履历就看完了。

这不是 PPT 动画,是一个真能跑的 Web 3D 项目:React 19 + React Three Fiber v9 + three.js + Vite 搭的 3D 迷宫,迷宫里每一根光柱对应一段真实经历,走到就解锁。

这篇文章前半部分带你俯瞰整个项目的架构,后半部分直接上第一个真·干货------迷宫生成算法。

一、为什么要选 React Three Fiber?

先说结论:对 React 开发者来说,R3F 是入坑 Web 3D 性价比最高的那条路

原生 three.js 是命令式 API:你手写 new Scene()scene.add(mesh)renderer.render(),自己管渲染循环。它强大,但和 React 的"声明式 + 状态驱动 UI"格格不入------你要么把 three 对象塞进 useRef 手动同步,要么在 useEffect 里和 React 状态打游击。

R3F 把 three.js 包成了 React 组件:

tsx 复制代码
// 原生 three:命令式
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

// R3F:声明式,和写 <div> 一个手感
<mesh position={[0, 1, 0]}>
  <boxGeometry />
  <meshStandardMaterial color="orange" />
</mesh>

好处不只是"写法顺手":

  1. 声明式 = 可组合。迷宫、玩家、光柱、弹窗都是组件,能像搭积木一样拼,props 变化自动重渲染。
  1. 能直接接 React 状态 。玩家走到了哪、该弹哪段经历,用熟悉的 useState / 单例 store 管就行,不用另学一套状态系统。
  1. 生态现成@react-three/drei 把 OrbitControls、loader、性能面板都封装好了,省下大量样板代码。

一句话概括:React 当 UI 框架,R3F 当 3D 视图层,Three.JS 当底层渲染,Vite 当构建。


二、项目长什么样:架构鸟瞰

很多人第一次看 R3F 项目会懵:3D 的东西和普通的 React 组件怎么混在一起?先看顶层状态机(src/App.tsx):

tsx 复制代码
const [status, setStatus] = useState<'menu' | 'playing' | 'won'>('menu');

整个应用就是 menu → playing → won 三个状态:

  • menu:欢迎页(WASD / 鼠标提示,纯 DOM + CSS 做的,不进 Canvas)
  • playing:进入 <MazeGame>,真正的 3D 迷宫在这里
  • won:通关页,显示用时

关键的 3D 部分在 MazeGame 里,组件树长这样:

css 复制代码
App
├─ MenuScreen      欢迎页(纯 DOM)
├─ MazeGame
│   ├─ <Canvas>    ← R3F 的根,声明式场景入口
│   │   └─ SceneContent
│   │       ├─ ambientLight        环境光
│   │       ├─ MazeEnvironment      迷宫网格 + 场景模型
│   │       └─ Player               第一人称控制器
│   ├─ HUD         计时 / 小地图(DOM 浮层)
│   └─ Description  经历弹窗(DOM 浮层)
└─ WinScreen       通关页(纯 DOM)

一句话记住这个分层: <Canvas> 是 3D 世界的大门,门里是 SceneContent(场景),门外是 HUD / Description 这些 DOM 浮层 。Player 在 useFrame 里每帧更新自己的世界坐标,同时把"我在哪个格子"写进一个全局 store(后面第 4 篇专门讲),光柱和弹窗据此触发。

tsx 复制代码
// src/components/Game.tsx 节选
function SceneContent({ maze, gameRef, onWin }) {
  const { scene, gl, camera } = useThree();
  useEffect(() => {
    // 背景和清屏色都设成暖光色:避免 Canvas 首帧先闪一下黑屏
    scene.background = new THREE.Color(WARM_LIGHT_BG);
    gl.setClearColor(WARM_LIGHT_BG);
  }, [scene, gl]);

  return (
    <>
      <ambientLight intensity={AMBIENT_INTENSITY} color={AMBIENT_COLOR} />
      <MazeEnvironment maze={maze} />
      <Player maze={maze} gameRef={gameRef} onWin={onWin} />
    </>
  );
}

小贴士:WARM_LIGHT_BG 这个暖色背景色同时设给了 scene.backgroundgl.setClearColor。别小看这一行------只设 background 不设 clearColor,首帧 Canvas 挂载前会先黑一下,观感很掉价。这是后面第 7 篇"氛围营造"里的一个小坑。

到这里,"是什么 + 凭什么"讲完了。接下来进入真正的硬核:迷宫是怎么生成的


三、迷宫生成算法:递归回溯 + 2N+1 网格

这是整个项目最适合当"R3F 第一课"的部分------算法本身不难,但它把"数据 → 3D 网格"的链路讲透了。

3.1 迷宫的本质:一张 wall / path 二维网格

别被"3D"吓到。底层迷宫其实就是一张二维表,每个格子 (cell) 只有两种状态:

ts 复制代码
// src/game/types.ts
export type CellType = 'wall' | 'path';

export interface Cell {
  type: CellType;
  visited: boolean; // 仅生成阶段 DFS 用
}

visited 是生成算法临时用的标记,迷宫生成完就没什么用了。cells[col][row] 就是这整张表。

3.2 2N+1 扩展网格:让"挖墙"变得极简

最经典的迷宫生成本质是**"在奇数坐标上走格子,偶数坐标当墙"**。具体做法:

  • 把输入尺寸强制转成奇数(偶数就 +1);
  • 所有格子先初始化成 wall
  • 真正的"路"只出现在奇数坐标:(1,1)、(1,3)、(3,1)、(3,3)...;
  • 四周一圈永远是墙,起点 (1,1),终点 (width-2, height-2)

为什么这么设计?因为这样"路"和"路"之间天然隔着一格墙。挖通两段路时,只需把中间那一格墙也设为 path,物理上就连通了------不用处理"半格墙"这种尴尬情况。

ts 复制代码
// src/game/mazeGenerator.ts · buildMaze 开头
const w = width % 2 === 0 ? width + 1 : width;   // 强制奇数
const h = height % 2 === 0 ? height + 1 : height;

// 初始化:全是墙
const cells: Cell[][] = [];
for (let c = 0; c < w; c++) {
  cells[c] = [];
  for (let r = 0; r < h; r++) {
    cells[c][r] = { type: 'wall', visited: false };
  }
}

3.3 迭代 DFS 挖墙(递归回溯)

递归回溯(Recursive Backtracker)是生成"完美迷宫"(任意两点间只有唯一通路)最常用的方法。这里用的是迭代版(用栈代替函数递归),避免大迷宫爆栈。

核心四步:

  1. 从起点 (1,1) 出发,压栈,标记为 path + visited
  1. 看四个方向(坐标表示顶点位置,步长 2,跳过中间那格)有没有"未访问的邻居";
  1. 随机挑一个邻居,把"当前格和邻居之间那一格"以及"邻居"都设为 path,邻居压栈;
  1. 没邻居可走就出栈(回溯),直到栈空。
ts 复制代码
// src/game/mazeGenerator.ts · 挖墙核心
const dirs: Array<[number, number]> = [
  [0, -2], // N
  [2, 0],  // E
  [0, 2],  // S
  [-2, 0], // W
];

while (stack.length > 0) {
  const [c, r] = stack[stack.length - 1];

  // 收集未访问、有效的"跳两格"邻居(含中间格坐标)
  const candidates: Array<[number, number, number, number]> = [];
  for (const [dc, dr] of dirs) {
    const nc = c + dc, nr = r + dr;
    if (nc < 1 || nc >= w - 1 || nr < 1 || nr >= h - 1) continue; // 不越界
    if (cells[nc][nr].visited) continue;                         // 不重复
    candidates.push([nc, nr, c + dc / 2, r + dr / 2]);           // [nc,nr,midC,midR]
  }

  if (candidates.length === 0) { stack.pop(); continue; }       // 死路,回溯

  // 随机选一个,挖通"中间墙 + 邻居"
  const [nc, nr, midC, midR] = candidates[Math.floor(Math.random() * candidates.length)];
  cells[midC][midR].type = 'path';
  cells[nc][nr].type = 'path';
  cells[nc][nr].visited = true;
  stack.push([nc, nr]);
}

为什么用迭代而不是递归? 递归写法更直观,但 JS 没有尾递归优化,迷宫一大(几千格)递归深度直接爆栈。stack 模拟调用栈,深度只受内存限制,稳得多。

3.4 解路径长度约束:让难度可控

光生成"完美迷宫"还不够------纯随机的迷宫,解路径可能短得 5 步就到终点(太水),也可能绕得离谱(劝退)。这个项目约束了解路径长度在 25, 35

ts 复制代码
const SOLUTION_PATH_MIN = 25;
const SOLUTION_PATH_MAX = 35;
const MAX_GENERATE_ATTEMPTS = 500;

实现很巧妙:DFS 栈本身就是"起点 → 当前格"的路径 。当算法首次到达终点时,把栈还原成完整路径,立刻量长度------不达标就整局作废、重新生成:

ts 复制代码
// 首次到达终点 (exitC, exitR) 时记录解路径
if (solutionPath.length === 0 && nc === exitC && nr === exitR) {
  const rawPath: Array<[number, number]> = [];
  for (let i = 0; i < stack.length; i++) {
    const [pc, pr] = stack[i];
    rawPath.push([pc, pr]);
    if (i < stack.length - 1) {
      const [nxc, nxr] = stack[i + 1];
      rawPath.push([(pc + nxc) / 2, (pr + nxr) / 2]); // 补上中间 mid 格
    }
  }
  solutionPath = buildPathCells(rawPath);
  if (!acceptAny && (solutionPath.length < 25 || solutionPath.length > 35)) {
    return null; // 不合格,直接放弃本次生成
  }
}

外层用 generateMaze 包一层重试,最多 500 次;兜底第 501 次放弃长度约束强制接受,防止极端情况下死循环:

ts 复制代码
export function generateMaze(width: number, height: number): MazeData {
  for (let attempt = 0; attempt < 500; attempt++) {
    const maze = buildMaze(width, height);
    if (maze) return maze;
  }
  return buildMaze(width, height, true) as MazeData; // 兜底:放弃长度约束
}

这个"生成不合格就整局重来"的思路,比"生成后局部修补"简单且结果更干净。代价是平均多生成几次,但迷宫规模小(几十格),毫秒级,体感无差。

3.5 把 DFS 栈还原成"带方向"的 PathCell

解路径不只要"经过哪些格",还要知道每一步朝哪走 ------因为玩家初始面朝哪、光柱模型怎么摆,都依赖方向。所以 buildPathCells 给每个格算出 PathDirection

ts 复制代码
// src/game/types.ts
export type PathDirection = 't' | 'r' | 'b' | 'l'; // 上 / 右 / 下 / 左

export interface PathCell {
  c: number; r: number;
  dir: PathDirection;     // 从"前一个格"到"本格"的方向
  prePos?: { c: number; r: number };
  nextPos?: { c: number; r: number };
}
ts 复制代码
function getDirection(from: [number, number], to: [number, number]): PathDirection {
  const dc = to[0] - from[0];
  const dr = to[1] - from[1];
  if (dc > 0) return 'r';
  if (dc < 0) return 'l';
  if (dr < 0) return 't';
  return 'b';
}

首格没有"前一个",就反推"它到下一个格"的方向;这样整条解路径每个拐弯都带方向信息,后面做第一人称初始朝向、光柱朝向对齐时直接查表即可。

3.6 顺带:碰撞检测怎么用这张网格

迷宫数据出来了,下一步是"让人走"。这里先剧透一个关键函数 canMove------它把世界坐标转成格子坐标,用 AABB 占格检测判断是否撞墙:

ts 复制代码
// src/game/mazeGenerator.ts · canMove
export function canMove(px: number, pz: number, radius: number, maze: MazeData): boolean {
  const cellPx = px / CELL_SCALE;       // 世界 → 格子空间
  const cellPz = pz / CELL_SCALE;
  const cellRadius = radius / CELL_SCALE;

  const minCol = Math.floor(cellPx - cellRadius);
  const maxCol = Math.floor(cellPx + cellRadius);
  const minRow = Math.floor(cellPz - cellRadius);
  const maxRow = Math.floor(cellPz + cellRadius);

  for (let c = minCol; c <= maxCol; c++) {
    for (let r = minRow; r <= maxRow; r++) {
      if (c < 0 || c >= maze.width || r < 0 || r >= maze.height) return false; // 越界
      if (maze.cells[c][r].type === 'wall') return false;                      // 撞墙
    }
  }
  return true;
}

注意 CELL_SCALE:格子坐标乘以它才变成 three.js 世界单位。这个函数本身不管"怎么移动",只回答"站在这合法吗"------具体"怎么一步步挪还不穿墙"是下一篇(第一人称控制器)的重点。


小结

这篇文章覆盖了两块:

  1. 选型与架构 :R3F 把 three.js 包成声明式 React 组件,对 React 开发者最友好;项目是 menu → playing → won 状态机,3D 世界在 <Canvas> 里,HUD / 弹窗是 DOM 浮层。
  1. 迷宫生成 :用「2N+1 奇数网格 + 迭代 DFS 递归回溯」生成完美迷宫,靠"首次到达终点即量解路径长度"约束难度在 25--35 格,并把 DFS 栈还原成带方向的 PathCell,顺带用格子 AABB 做碰撞检测。

源码对应:src/App.tsxsrc/components/Game.tsx(架构)、src/game/mazeGenerator.tssrc/game/types.ts(算法)。

下篇预告

迷宫有了,但现在是"死的"------怎么让人走进去、用鼠标转视角、WASD 移动还不穿墙?下一篇讲第一人称控制器:Pointer Lock 锁鼠标、WASD 向量移动、越肩相机,以及"把整帧位移拆成小步 + 分轴滑墙"的防穿墙秘诀。


如果这篇对你入坑 R3F 有帮助,点个赞 / 收藏,下一篇准时更新。系列共 7 篇,追更不迷路。

相关推荐
还有多久拿退休金3 小时前
不调多模态,纯文本大模型如何给系统操作配上截图
前端·llm·aigc
hunterandroid4 小时前
HarmonyOS WebSocket 实战:断线重连、心跳保活与连接状态机设计
前端
hunterandroid4 小时前
StateFlow 与 SharedFlow 的边界:状态与事件的正确建模
android·前端
心念科技4 小时前
1、搜索表单 xnSearch(基于:心念后台,后端 Java 21 + Spring Boot 4 + Spring Cloud,前端提供 ReactVue3+TS、Vue3+JS、Vue2+JS
前端
计算机魔术师5 小时前
Dwarkesh Patel 对 OpenAI/Hugging Face 事件的爆款解读被指危险误导
前端
自进化Agent智能体6 小时前
Hermes GitHub PR 审查 —— 自动化代码评审
前端
涛涛ing7 小时前
OpenAI Astra 泄露:零样本生成 3D 网页,前端开发者慌了吗?
前端
ssshooter8 小时前
现在网页都能提供 MCP 了?!
前端·人工智能·程序员
杉氧8 小时前
状态管理变迁史:为什么我们放弃了 Redux 选择 Zustand?
android·前端·react native