R3F 第一人称漫游与碰撞检测:Pointer Lock + 不穿墙的滑墙秘诀(中)

迷宫生成出来了,但现在它是"死的"------你只能站在起点干瞪眼。这一篇讲怎么让人真正走进去:鼠标转视角、WASD 移动、撞墙不穿、贴着墙还能顺滑滑行。

这是 R3F 新手最容易被卡住的一环:three.js 的 useFrame 每帧回调里,你要同时管"输入、坐标、碰撞、相机",逻辑一多就乱。这篇文章按模块拆开讲,每块都能单独抄走。


一、整体思路:所有逻辑都跑在 useFrame 里

R3F 里"每一帧要更新的东西"写在 useFrame 回调里,它大概等价于原生 three 的 requestAnimationFrame 循环,但 R3F 帮你接好了渲染时机。第一人称漫游的全部核心(读输入 → 算位移 → 碰撞 → 摆相机)都在这个循环里:

ts 复制代码
useFrame((_, delta) => {
  if (wonRef.current) return;
  const dt = Math.min(delta, 0.05); // 钳制 delta,防止切后台回来"跳一大步"
  // 1. 读方向键转向
  // 2. 读 WASD 算位移
  // 3. 分步 + 分轴碰撞
  // 4. 更新玩家模型和相机
});

delta 是两帧间隔(秒)。把它钳制在 0.05 以内很关键:浏览器切到后台再切回来,delta 可能暴涨,不钳制的话玩家会瞬间"瞬移"穿墙。

关键心智模型:useFrame 里只操作 refposRefyawRef),不要 setState 。状态变了 React 会重渲染,每帧重渲染直接卡死。坐标和朝向都存在 useRef 里,渲染交给 three 直接改 mesh.position,只有"通关""弹窗"这种低频事件才碰 React 状态。


二、Pointer Lock:锁定鼠标控视角

第一人称/越肩视角的标配是 Pointer Lock(指针锁定) :点击画布后鼠标隐藏并被"锁"在窗口里,移动鼠标只改视角、不跑出页面。

ts 复制代码
// src/components/SpherePlayer.tsx
const canvas = gl.domElement;

const handleClick = () => {
  if (document.pointerLockElement !== canvas) {
    canvas.requestPointerLock(); // 点击锁定
  }
};
const handleLockChange = () => {
  isLockedRef.current = document.pointerLockElement === canvas; // 锁状态
};

const handleMouseMove = (e: MouseEvent) => {
  if (!isLockedRef.current) return;
  yawRef.current   -= e.movementX * MOUSE_SENSITIVITY; // 左右转头
  pitchRef.current -= e.movementY * MOUSE_SENSITIVITY; // 上下抬头/低头
  // 钳制 pitch,防止"翻面"
  const maxPitch = Math.PI / 2 - 0.05;
  pitchRef.current = Math.max(-maxPitch, Math.min(maxPitch, pitchRef.current));
};

三个点要讲清:

  1. movementX / movementY:指针锁定时鼠标"相对上一帧的位移",跟屏幕坐标无关,正适合做视角增量。
  1. 灵敏度 MOUSE_SENSITIVITY = 0.0022:乘在位移上。这个值偏小才舒服,大了转一下头天旋地转。
  1. pitch 必须夹紧 :上下俯仰限制在 ±(π/2 - 0.05)。不夹紧的话,盯着天或地继续上抬,视角会翻过去------俗称"万向锁翻面",观感很崩。

yaw(左右)不用夹,转一圈回到原样即可。


三、WASD 向量移动

视角有了,下一步是"前后左右走"。核心是把"按键"翻译成"世界坐标的位移向量",靠 yaw 算出前方向和右方向:

ts 复制代码
// forward = (-sin(yaw), -cos(yaw));right = (cos(yaw), -sin(yaw))
const forwardX = -Math.sin(yawRef.current);
const forwardZ = -Math.cos(yawRef.current);
const rightX   =  Math.cos(yawRef.current);
const rightZ   = -Math.sin(yawRef.current);

let dx = 0, dz = 0;
if (keysRef.current.has('KeyW')) { dx += forwardX; dz += forwardZ; } // 前
if (keysRef.current.has('KeyS')) { dx -= forwardX; dz -= forwardZ; } // 后
if (keysRef.current.has('KeyA')) { dx -= rightX;   dz -= rightZ;   } // 左
if (keysRef.current.has('KeyD')) { dx += rightX;   dz += rightZ;   } // 右

// 归一化 + 乘速度 + 乘 dt(帧率无关)
const len = Math.sqrt(dx * dx + dz * dz);
const isRunning = keysRef.current.has('ShiftLeft') || keysRef.current.has('ShiftRight');
if (len > 0) {
  const speed = MOVE_SPEED * (isRunning ? RUN_SPEED_MULTIPLIER : 1); // 3.2 * 1.6
  dx = (dx / len) * speed * dt;
  dz = (dz / len) * speed * dt;
}

几个必须记的坑:

  • 一定要归一化 :同时按 W+D 时 dx,dz 合成向量长度变 √2,不归一化斜着走会比直走快。除以 len 就消除了。
  • 一定乘 dt :这样 speed 是"单位/秒",不同帧率下移动距离一致,不会高刷屏跑得快、低刷屏跑得慢。
  • MOVE_SPEED = 3.2 是世界单位/秒,RUN_SPEED_MULTIPLIER = 1.6 是 Shift 加速倍率。

方向公式推导:把 yaw=0 代入,forward = (0, -1) 即 -Z 方向,符合 three 里"默认朝 -Z 看"的约定。right 就是 forward 顺时针转 90°。


四、相机:越肩跟随(顺带教你改纯第一人称)

这个项目用的是越肩跟随相机------相机在玩家后上方,视线越过模型头顶看向前方道路(比纯第一人称更像"看自己的角色探险",也方便让模型露脸)。

ts 复制代码
// USE_MOUSE 时启用越肩相机
const camDist = 0.5;    // 身后距离
const camHeight = 0.45; // 胸口高度
const lookAhead = 1.5;  // 视线投向前方的距离
camera.position.set(
  px - forwardX * camDist,  // 玩家位置 - 前方向*距离 = 身后
  camHeight,
  pz - forwardZ * camDist,
);
camera.lookAt(
  px + forwardX * lookAhead,   // 看向玩家前方
  pitchRef.current * 1.0,     // pitch 控制俯仰(抬头看更远)
  pz + forwardZ * lookAhead,
);

公式记忆:behind = -forward,所以相机位置 = 玩家 - 前方向 × 距离,落在玩家正后方;lookAt 落在玩家 + 前方向 × 距离,即前方。pitch 直接加在 lookAt 的 y 上,鼠标上下移就抬头/低头。


五、碰撞检测:canMove 回顾

移动向量算出来了,但"往哪走合法"由第 2 篇讲过的 canMove 决定。复习一下核心:把世界坐标除以 CELL_SCALE 转成格子坐标,玩家当成一个小方块 AABB,覆盖到的每个格子只要"越界"或"是墙"就判不合法。

ts 复制代码
// src/game/mazeGenerator.ts
const cellPx = px / CELL_SCALE;   // CELL_SCALE = 1,所以世界单位 == 格子数
const cellRadius = radius / CELL_SCALE;
const minCol = Math.floor(cellPx - cellRadius);
const maxCol = Math.floor(cellPx + cellRadius);
// ... 遍历覆盖格,遇 wall / 越界 return false

CELL_SCALE = 1,意味着 1 个世界单位正好 1 格,坐标直读,调试时心智负担小。


六、防穿墙秘诀:分步 + 分轴滑墙

光有 canMove 还不够。如果一帧位移很大(比如 speed*dt 一帧跨过一整格墙),canMove 只在"终点"判断,会直接从墙这头跳到墙那头------穿墙了。

这个项目用两招解决:

第一招:把整帧位移拆成 ≤0.2 格的小步。 MAX_STEP = 0.2 * CELL_SCALE,每步都单独用 canMove 校验:

ts 复制代码
const MAX_STEP = 0.2 * CELL_SCALE; // 每步最大 0.2 格
const radius = PLAYER_RADIUS + MOVE_EPSILON + 0.05; // 0.1 + 0.05 + 0.05 = 0.2

// X 轴:拆成 ceil(|dx|/MAX_STEP) 步,逐步推进
const stepCountX = Math.ceil(Math.abs(dx) / MAX_STEP);
const stepX = dx / stepCountX;
for (let i = 0; i < stepCountX; i++) {
  const next = posRef.current.x + stepX;
  if (canMove(next, posRef.current.z, radius, maze)) posRef.current.x = next;
  else break; // 撞墙就停,不再往前
}

第二招:X、Z 分轴推进。 先把 X 走完(撞墙就停 X),再走 Z(撞墙就停 Z)。这样"斜着撞墙"时,被挡的那一轴停下、另一轴继续------视觉上就是贴着墙顺滑滑行,不会卡死也不会穿墙。

ts 复制代码
// Z 轴同理,独立于 X
const stepCountZ = Math.ceil(Math.abs(dz) / MAX_STEP);
const stepZ = dz / stepCountZ;
for (let i = 0; i < stepCountZ; i++) {
  const next = posRef.current.z + stepZ;
  if (canMove(posRef.current.x, next, radius, maze)) posRef.current.z = next;
  else break;
}

PLAYER_RADIUS = 0.1,实际碰撞半径加了 MOVE_EPSILON(0.05)0.05 缓冲,变成 0.2。这 0.1 的冗余是给"贴墙"留的容差------避免玩家紧贴墙时因浮点抖动反复触发碰撞、走不动。

为什么是 0.2 格一步? 墙格宽 1 格,每步 ≤0.2 格意味着即使高速奔跑,单步也远小于一格,物理上不可能跨过一整堵墙。speed=3.2*1.6≈5.1 时一帧(dt≈0.016)位移约 0.08,拆成 1 步足矣;只有掉帧(dt 被钳到 0.05)时才拆成 2-3 步。


七、调试技巧:r3f-perf 实时看状态

写控制逻辑最怕"不知道哪错了"。项目里已经挂了 r3f-perf(虽然名字带 r3f,它是 r3f-perf 包):

tsx 复制代码
// src/components/Game.tsx
<Perf position="top-left" />

左上角会显示 FPS、draw call、显存等。配合 @react-three/leva可以运行时调 MOVE_SPEEDMOUSE_SENSITIVITY 这类参数,边跑边调手感,比改代码重 build 快十倍。

新手最常见三个 bug:① 忘了 dt,高刷屏跑飞;② pitch 没夹紧,视角翻面;③ 在 useFramesetState 导致卡顿。对照上面都避开了。


小结

这篇把"人走进迷宫"的四大块讲完了:

  1. Pointer Lock :点击锁鼠标,movementX/Yyaw/pitchpitch 必须夹紧防翻面。
  1. WASD 移动 :由 yaw 推前/右向量,归一化 + 乘 speed*dt 保证帧率无关、斜走不加速。
  1. 越肩相机 :相机在玩家后上方,lookAt 前方;附了改纯第一人称的一行代码。
  1. 防穿墙canMove 做格子 AABB 检测 + "整帧位移拆 ≤0.2 格小步" + "X/Z 分轴推进",实现贴墙滑行。

下篇预告

人能走了,但迷宫还是空的------怎么把"我的职业经历"塞进迷宫、让玩家走到某根光柱前自动弹出简历?下一篇讲单例 Store 场景系统:不引 zustand,用一个 class 单例 + 订阅者模式,让 Player、光柱、弹窗共享"玩家在哪、该弹谁"。

相关推荐
LSCLikeApple28 分钟前
入门promise
前端
爬楼的猪30 分钟前
DeepSeek Harness 本地安装和对话过程的网络问题
前端
labixiong31 分钟前
button按钮原生开关弹窗,零 JS 搞定80%交互场景
前端·javascript·html
悟空瞎说36 分钟前
Vue3 应用实例 API
前端
隔岸观火烧连营38 分钟前
如何用 WebCodecs 在浏览器里实现高清录屏 —— 无插件、无水印、直接导出 MP4
前端·javascript
杉氧39 分钟前
页面栈与路由:React Navigation 与 Expo Router 深度实践
android·前端·react native
用户9314563556640 分钟前
别再满屏 try-catch 了:聊聊异常处理的正确姿势
前端
葡萄城技术团队1 小时前
ERP 内置 BI(上):为什么业务部门还在用 Excel 做分析?
前端
用户64596598710881 小时前
Jenkins CI/CD 实战:Vite 前端发布、Koa + PM2 后端部署、权限隔离与远程发布
前端