用 Three.js 做一个 Web 3D 非对称追猎 Demo:从场景、角色到手感调试

在这里插入图片描述

在这里插入图片描述

项目地址:

  • GitHub:https://github.com/typsusan-zzz/wildhunt-fullstack
  • Gitee:https://gitee.com/susantyp/wildhunt-fullstack
  • 在线体验:https://wildhunt-backend-production.up.railway.app

这篇文章想复盘的是一个完整 Web 3D 游戏 Demo 的前端部分。项目叫 WildHunt,中文名“荒野追猎”。它不是传统意义上的小游戏页面,而是一个带登录、大厅、房间、匹配、WebSocket 对局、3D 场景、角色动画、实时同步和免费线上部署的全栈 Demo。玩法上,它是一个非对称追猎:狼要在有限时间里从鹿群中找出真人鹿;鹿要混入 AI 鹿群,控制自己的饥饿值、可疑度,并在关键时刻使用一次烟雾分身来误导狼。

前端最有意思的地方在于,它不是用 2D canvas 画一个简化原型,而是直接用 Three.js 组织真实 3D 场景。核心文件集中在 frontend/src/game/game-scene.ts 负责场景装配和主循环,game-input-controller.ts 负责键盘和移动端虚拟摇杆,game-terrain-sampler.ts 做地形采样,game-collision.ts 用空间哈希管理碰撞体,game-scent.ts 做狼的气味追踪特效,game-smoke-vfx.ts 做鹿的烟雾分身粒子,game-runtime-config.ts 把后端下发的对局参数统一规范化。

设计目标:浏览器里跑一个“像游戏”的 Demo

一开始做这个项目时,我没有把它当成一个纯展示页面,而是尽量按真实游戏工程去拆。前端要解决几个问题:

  1. 场景需要有空间感,玩家不是在平面上滑动,而是在有地形、有遮挡、有森林密度变化的荒野里移动。
  2. 狼和鹿的角色手感要不同。狼移动更快,有扑咬、气味追踪;鹿更脆弱,但可以进食、环顾、分身。
  3. AI 鹿群不能完全静止,否则真人鹿很容易暴露。AI 鹿需要有游荡、停顿、吃草、惊吓等状态。
  4. 正式对局里不能完全相信本地判定,前端要能接收服务端快照,把本地显示修正到权威状态。
  5. 资源加载不能靠固定延迟,地形、模型、HUD、音频和首帧服务端快照都准备好后再开局。

这些目标决定了前端不是“一个组件里写到底”的结构。game-scene.ts 虽然仍然是主入口,但周边模块已经把输入、碰撞、特效、配置、网络、加载闸门拆开。这样做的好处是,后续要调手感时,不需要在 2000 多行场景代码里到处翻。

场景搭建:Three.js 渲染管线和视觉氛围

项目的渲染入口是 frontend/src/game/game-scene.ts。它创建了 THREE.ScenePerspectiveCameraWebGLRenderer,并开启了阴影、SRGB 输出和 ACESFilmic tone mapping。为了让画面不只是“模型摆在地面上”,它还接了 EffectComposer,加入 RenderPassSSAOPassUnrealBloomPass。SSAO 用来加强地形、树木和角色脚下的接触阴影;Bloom 强度很低,只负责给画面一点点高光层次,而不是做成满屏发光。

场景的视觉参数集中在一个 LOOK 对象里,比如背景色、雾颜色、雾密度、曝光、半球光、太阳光和 Bloom 参数。移动端性能也单独考虑了:QUALITY.pixelRatio 会根据设备类型和 DPR 做限制,手机上不会盲目使用过高像素比;SSAO kernel radius 也会根据移动端和高 DPR 调低。这类小细节对 Web 3D 很关键,因为浏览器里没有原生游戏那样稳定的运行环境,稍微多一点后处理就可能让低端机掉帧。

资源方面,狼和鹿使用 GLTF 模型,加载器是 GLTFLoader,动画复制用 SkeletonUtils.clone。自然物包括树、灌木、草、岩石、蘑菇、悬崖等,统一作为模板加载,再在场景里批量摆放。素材路径放在 frontend/public/models/ 下,比如 models/quaternius/Wolf.gltfmodels/quaternius/Deer.gltfmodels/nature/tree_broad_01.glb。这种做法对 Vite 项目比较方便,开发环境和构建后的静态资源路径一致。

地形与自然物:不是随机撒点,而是有密度和路径

WildHunt match

场景里的荒野不是一个完全平的圆盘。项目里有 TerrainSampler,它接收一个 sampleHeightAt(x, z) 函数,用来查询任意位置的地形高度。它还提供 sampleNormalsampleSlopeisWalkable。核心思路是:用附近四个点的高度差估算法线,再根据法线的 y 分量判断坡度。sampleSlope = 1 - normal.y,坡度超过 maxWalkableSlope 的地方就不适合角色通过。

这一层很实用。角色移动时不能只改 x,z,还要把 y 对齐到地形高度;碰到太陡的坡要限制;树和石头也要落在地形表面,而不是悬空或陷入地下。TerrainSampler 把“场景几何”和“角色移动”之间的依赖收束成一个小接口,主场景里只需要不断调用 sampleHeightsampleSlope

自然物摆放也不是简单 for 循环随机坐标。项目里有森林中心点、空地、路径和密度函数。比如 FOREST_CLUSTER_CENTERS 控制树木聚集区域,FOREST_CLEARINGS 控制玩家活动空间,FOREST_PATHS 控制可通行路径。代码里通过 forestDensityAt(x,z) 计算某点的森林密度,路径影响和空地影响会降低密度,边缘和聚集中心会提高密度。再配合类似 Poisson disk 的采样方式,避免树木贴得太密。

这对玩法有直接影响:狼需要在树林和空地之间观察鹿群,鹿需要利用视觉遮挡和群体行为隐藏。自然物如果完全随机,画面可能热闹,但会破坏路线和可读性;如果太规则,又不像荒野。这里的实现介于两者之间,用少量规则生成一个可玩的空间。

碰撞:用空间哈希控制查询成本

Web 3D 游戏里最容易踩坑的是碰撞。这个项目没有接入完整物理引擎,而是自己维护轻量碰撞体。frontend/src/game/game-collision.ts 里有一个 SpatialHash 类,它把世界按固定 cell size 切成格子。每个碰撞体根据中心点和半径插入多个格子,查询时只查角色附近半径覆盖到的格子。

伪代码大概是这样:

const hash = new SpatialHash(10);
hash.insert({ center, radius, blocks: true, category: 'tree' });
const nearby = hash.query(new THREE.Vector2(player.x, player.z), 28);

为什么不用遍历所有碰撞体?因为自然物数量会比较多,尤其草、树、灌木和岩石批量摆放后,每帧全部遍历很浪费。空间哈希的意义是把“全场搜索”改成“附近格子搜索”。它不是最精确的物理系统,但对这个 Demo 的圆形碰撞和近距离避障已经够用。

调试上,游戏里按 F 可以切换 debug 碰撞范围。updateDebugColliders() 会根据玩家所在格子绘制附近碰撞环,阻挡物和非阻挡物用不同颜色显示。这个功能非常有用,因为 3D 场景里“看起来能过但过不去”或者“看起来有树但穿过去”都很常见,必须有可视化手段快速定位。

输入层:键盘、移动端和一次性动作

输入逻辑放在 game-input-controller.ts。它把持续输入和一次性动作分开:

  • 持续输入:forwardbackleftrightsprint
  • 一次性动作:wolfPouncewolfScentdeerEatdeerLookdeerCamouflage
  • 每个一次性动作都有一个 Seq 字段,比如 wolfPounceSeqdeerCamouflageSeq

这个设计是为了和 WebSocket 同步配合。移动按键可以每 50ms 上报一次当前状态,但“扑咬”“分身”这种动作不能只依赖布尔值。假设玩家按下空格很快松开,如果网络发送周期刚好错过,就可能丢动作。所以每次触发动作时,除了把布尔值设为 true,还会递增对应序号。网络层只要发现序号比上次发送的大,就知道有一个新动作必须上报。

移动端方面,项目实现了一个虚拟摇杆。pointerdown 记录 pointerId,pointermove 根据触点相对摇杆中心的偏移设置前后左右,pointeruppointercancel 重置输入。按钮区则根据当前角色切换含义:狼的按钮是气味和扑咬,鹿的按钮是进食、环顾、分身。这样同一套 UI 可以适配不同阵营。

角色状态:狼强在追踪,鹿强在伪装

狼的状态主要围绕移动、体力、扑咬、气味追踪。扑咬会给狼一个短时间冲量,命中目标才会产生结果;气味追踪会寻找最近的真人鹿,然后用粒子带出方向提示。game-scent.ts 里用 BufferGeometry 管理最多 520 个粒子,每个粒子有位置、透明度、大小、生命周期、速度和相位。粒子不是随机爆开,而是沿狼到目标鹿的方向分布,再叠加风向和摆动,这样看起来像“气味被风带过来”。

鹿的状态更偏生存。它有饥饿值和可疑度。移动、冲刺、离狼太近、行为不自然都会提高风险;进食可以恢复饥饿,环顾可以得到狼的大致方向;烟雾分身一局一次,释放时通过 SmokeVfxSystem 生成一团 sprite 粒子,并在服务端快照里出现 decoy 鹿。game-smoke-vfx.ts 里先尝试加载 /textures/vfx/smoke.png,失败时用 Canvas 动态生成 fallback 纹理。这一点很实际,资源加载失败时特效不会直接消失。

AI 鹿也不是静态模型。它们有 wanderpausegrazelookstartleddead 等状态。真实鹿和 AI 鹿的移动差异需要控制得很微妙:如果 AI 太机械,真人玩家很难隐藏;如果 AI 太随机,玩家也无法模仿。项目里通过状态时间、游荡角度、速度、饥饿和可疑度来驱动鹿群,至少让鹿群看起来有基本生命感。

加载闸门:正式开局不能只等一个 setTimeout

正式对局里,开局时机尤其重要。项目用 game-loader.ts 定义了一个 LoadingGate

export type LoadingGate = {
  terrain: boolean;
  nature: boolean;
  models: boolean;
  hud: boolean;
  audio: boolean;
  firstSnapshot: boolean;
};

只有地形、自然物、模型、HUD、音频和首帧服务端快照都准备好,才算真正 ready。这个设计比固定等待 3 秒靠谱得多。固定等待在本地可能没问题,但线上免费部署会有冷启动,用户网络也不稳定。如果首帧快照没到就开局,客户端可能先用本地随机状态跑起来,随后又被服务端修正,观感会很糟。

小结

这个项目的前端部分给我的最大体会是:Web 3D 游戏并不一定要一上来接入庞大的引擎。Three.js 本身负责渲染,剩下的输入、碰撞、状态、资源、网络都可以按需求逐步搭起来。关键是不要把它当成“页面动效”,而要当成一个小型实时系统。

如果要继续优化,我会优先做三件事:第一,把 game-scene.ts 继续拆小,把世界生成、角色控制、AI 行为和 HUD 更新独立出来;第二,给自然物和角色做更明确的 LOD 或实例化策略;第三,把本地预测和服务端快照插值做得更平滑。即便如此,现在这个 Demo 已经能展示一个完整技术链路:Vite + TypeScript + Three.js,在浏览器里跑出有场景、有角色、有技能、有联机同步的 3D 对局。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐