1. 迷雾项目从想法到可玩网页1.1 这个游戏到底做了什么《迷雾》是一个第一人称探索小游戏玩家醒来时被困在一片灰白色浓雾笼罩的树林里能见度不超过三四米头顶没有阳光四周静得只能听见自己的脚步声。手里唯一的光源不过是一盏散发着暖黄光晕的提灯照亮脚前方一小片区域。玩家需要在这片白茫茫的环境中摸索前行通过寻找散落在林间的发光石柱收集能量被激活的石柱会短暂照亮周围驱散部分雾气帮助你在迷路之前摸清地形。最后一步是在这些瞬间清晰的视野中辨认出藏在雾里的木屋入口。整个过程没有任何敌人、谜题或血液核心体验只有两个方向感与沉浸感。方向感的压迫来自雾——你看不清远处只有靠地形特征和个人记忆建立空间感沉浸感则来自第一人称的视角、灯光跟随、脚步触碰地面的反馈还有雾在移动时微微翻涌的质感。技术实现上我用的是Three.js加原生JavaScript纯网页项目没有使用Unity或Godot也没有引入任何游戏框架。服务器随便一个静态托管就能跑浏览器打开即玩完全不需要安装。这个卖点听着简单但做起来比想象中麻烦后面我会详细讲。1.2 为什么选Three.js而不是游戏引擎很多人问我既然要做第一人称游戏为什么不用UnityUnity导出WebGL其实也能在浏览器里跑而且自带物理引擎、遮挡剔除、音频系统看起来成熟得多。但我的出发点很明确我想要一个轻量、可维护、纯前端的项目最好单文件压缩后不超过几百KB。在这个需求下用Unity属于杀鸡用牛刀。Unity的WebGL导出包通常体量巨大首屏加载动辄几十MB而且和网页前后端的交互并不顺畅。Three.js则刚好卡在中间它提供WebGL的完整封装帮你处理了底层矩阵运算、着色器编译、渲染管线但又不像引擎那样逼你遵循特定架构。你可以用几十行代码搭一个可用的3D场景自由度极高。还有一点很关键Three.js是纯JavaScript库直接嵌入到现有前端项目毫无障碍。比如我后期想用Vue3加TypeScript做一个机房可视化监控系统完全可以在认真学习Three.js的核心之上快速切换渲染层和业务层完全分离。这正是我把技术栈锁定在Three.js路线上的核心理由。1.3 项目文件结构与代码组织实操上我没有用打包工具纯手写模块化脚本。这个游戏的文件结构非常简洁mist-game/ ├─ index.html ├─ css/ │ └─ main.css └─ js/ ├─ main.js // 场景初始化、渲染循环 ├─ player.js // 第一人称控制器、移动、碰撞 ├─ world.js // 地形、树木、石柱、木屋的生成 ├─ fog.js // 雾效与环境光配置 └─ utils.js // 随机数、向量工具函数你可能会好奇为什么不用ES6模块的import语法因为我想让项目兼容性足够好直接双击index.html就能跑而file://协议下import会直接报错。在本地调试时我用的是普通script标签按顺序加载部署的时候再扔到Nginx上这就是我对纯网页可直接游玩的理解——连开发调试阶段都不要有额外门槛。2. 第一人称视角与迷雾核心实现2.1 鼠标视角是怎么做出来的第一人称游戏的核心组件是摄像机跟随鼠标旋转。很多教程会让你直接找three.js的examples里的PointerLockControls但说实话我建议你在做游戏时至少手动实现一次这样你能彻底搞清楚底层发生的事。我的实现不长总共三十行左右。核心思路是用Pointer Lock API锁住鼠标然后监听mousemove事件通过event.movementX和event.movementY来更新摄像机的偏航角yaw和俯仰角pitchconst yaw new THREE.Object3D(); // 水平方向容器 const pitch new THREE.Object3D(); // 垂直方向容器 yaw.add(pitch); pitch.add(camera); scene.add(yaw); canvas.addEventListener(click, () { canvas.requestPointerLock(); }); document.addEventListener(mousemove, (e) { if (document.pointerLockElement ! canvas) return; yaw.rotation.y - e.movementX * 0.002; pitch.rotation.x - e.movementY * 0.002; pitch.rotation.x Math.max(-Math.PI / 2, Math.min(Math.PI / 2, pitch.rotation.x)); });为什么用两层Object3D包裹相机而不是直接改camera.rotation因为第一人称视角的旋转规则是左右旋转绕世界坐标Y轴上下旋转绕自身X轴。如果直接把旋转累加到camera.rotation上会有万向锁问题当俯仰角达到90度时左右旋转会变得极其诡异。用yaw和pitch两层结构就没有这问题代码也更清晰。灵敏度0.002是一个我反复试出来的值也就是鼠标移动500像素视角旋转1弧度。这个数值不能说绝对适合所有人但对大多数玩家来说算是一个不头晕也不迟钝的折中点。2.2 WASD移动与脚步细节移动部分相对简单但有一个容易被忽略的细节移动方向必须基于玩家当前的水平朝向而不是相机完整朝向。如果你直接用camera.rotation计算方向向量当玩家低头看地面时按W会向前上方移动这就脱离第一人称的直觉了。正确做法是先用yaw计算出水平方向再构造移动向量function getMovementDirection(keys) { const direction new THREE.Vector3(); const forward new THREE.Vector3(0, 0, -1); forward.applyQuaternion(yaw.quaternion); const right new THREE.Vector3(1, 0, 0); right.applyQuaternion(yaw.quaternion); if (keys.KeyW) direction.add(forward); if (keys.KeyS) direction.sub(forward); if (keys.KeyA) direction.sub(right); if (keys.KeyD) direction.add(right); return direction.normalize(); }移动时我用了一个很小但很提升体验的细节相机在Z轴上加了微弱的正弦波振动模拟走路时身体的起伏。频率和移动速度挂钩让画面看起来有人感。这个振幅非常小只有0.03否则会让玩家产生晕动症。2.3 雾效Fog与FogExp2到底怎么选这个游戏的核心是雾所以雾的实现直接决定视觉基调。Three.js提供了两种雾THREE.Fog和THREE.FogExp2。THREE.Fog是线性雾参数是near和far物体在near距离内完全清晰在near到far之间逐级褪色超过far则完全不可见。THREE.FogExp2是指数雾只有一个密度参数density物体透明度随距离呈指数衰减。两种雾在视觉上有细微差别。线性雾比较硬有一个明确的可见距离边界适合表现大雾天的远景有一种浓雾从某条线开始的感觉指数雾更绵密不存在明显边界场景是逐渐模糊直到消失适合做那种走入雾里被吞没的感觉。我用了FogExp2密度设置为0.08。这个数字是我反复调出来的低于0.04雾就太淡目光能穿透整个场景高于0.12雾又太浓连脚下的路都看不清玩家会陷入迷路循环。0.08刚好保留大约3到4米的可视距离符合这个游戏的核心玩法。scene.fog new THREE.FogExp2(0xc8c8c8, 0.08);雾的颜色我选了浅灰白色0xc8c8c8而不是纯白色0xffffff。原因很直接纯白会反射出场景中明显的色偏反而显得假。浅灰白色让雾有厚度感和提灯的暖黄色形成对比视觉色调更平衡。2.4 雨、雪、雾同源粒子系统的实现思路搜索热点里很多人问three.js雨雪雾怎么实现。其实雨和雪的实现路径和雾完全不同——雾是场景级的fog属性而雨雪是动态粒子系统。我在做《迷雾》时没有加雨雪但我测试过这个技术简单说一下。雨雪都是用BufferGeometry加PointsMaterial做的。每个雨滴是一个顶点通过Points渲染成一系列粒子。雨的核心是速度每帧更新粒子Y轴位置向下移动重置超出范围的粒子到顶部。雪则多了水平飘移和自旋要更复杂一些const geometry new THREE.BufferGeometry(); const positions new Float32Array(count * 3); for (let i 0; i count; i) { positions[i * 3] (Math.random() - 0.5) * range; positions[i * 3 1] Math.random() * height; positions[i * 3 2] (Math.random() - 0.5) * range; } geometry.setAttribute(position, new THREE.BufferAttribute(positions, 3)); const material new THREE.PointsMaterial({ color: 0xffffff, size: 0.05, transparent: true, opacity: 0.6, });关键是大小和密度。雨滴的size要控制在0.02到0.04太大会变成明显的白色噪点雪的size可以稍大配合低速下落才有飘的感觉。粒子数量建议不超过3000否则帧率会明显下滑。3. 场景搭建与交互玩法实现3.1 随机小树林树木、石头与路标《迷雾》的场景是一片小树林但我不打算手把手摆放每一棵树。用算法生成才是正道。树分为两类靠近玩家活动区域的放在固定位置确保碰撞和玩法可靠边缘地带大范围的随机散布纯刷氛围。每棵树由两部分组成树干用CylinderGeometry树冠用SphereGeometry。为了让树不至于千篇一律我加了三个随机参数树高、树干倾斜角度、树冠缩放比例。这样一颗算法树大概是function createTree() { const group new THREE.Group(); const trunkHeight 2 Math.random() * 1.5; const trunk new THREE.Mesh( new THREE.CylinderGeometry(0.08, 0.12, trunkHeight, 6), new THREE.MeshStandardMaterial({ color: 0x4a3525, roughness: 1 }) ); trunk.position.y trunkHeight / 2; group.add(trunk); const crown new THREE.Mesh( new THREE.SphereGeometry(0.5 Math.random() * 0.3, 7, 5), new THREE.MeshStandardMaterial({ color: 0x6b7f5a, roughness: 0.9 }) ); crown.position.y trunkHeight 0.2; crown.scale.x 1 Math.random() * 0.5; crown.scale.z 1 Math.random() * 0.5; group.add(crown); return group; }你可能注意到我把树干的分段数调得比较低。这比高精度模型更有手绘感同时也大幅减少顶点数量性能压力小。游戏里物件本来就多每个都高模的话帧率早就崩了。路标我用的是简单的长方体加一个球顶上面用CanvasTexture写了个箭头符号。很多教程处理文字都是直接画在Canvas上再贴到纹理这个思路最简单不用额外加载字体文件。3.2 灯光的层次与雾的相互作用灯光在迷雾游戏里比在普通场景里更讲究。雾会让所有材质颜色混入雾色如果没有足够强的局部光源场景会变成一片灰白。我用的是三盏灯第一盏是环境光强度只有0.15颜色偏冷蓝保证场景不是全黑但有基本的轮廓感知。第二盏是平行光模拟从雾上方的微弱阳光强度0.3颜色偏白。第三盏是跟随玩家移动的点光源挂在相机下方颜色是暖橙色强度1.5距离衰减设为2。这盏灯就是玩家手里的提灯也是整个视觉氛围的主角。一个重要教训Three.js里点光源的衰减参数在新版本中改成了物理模式。默认情况下点光源的强度单位是坎德拉如果你设置distance却忘了开physicallyCorrectLights效果会和预期完全不同。我在开发时直接设置了renderer.useLegacyLights false并用了新版本的光照强度单位这样参数更直观。灯光的颜色和雾色也有相互作用。雾的颜色越好灯光透过雾的效果越自然。我用了一个偏冷的灰白雾配合暖色提灯形成冷暖对比视觉上很舒服。3.3 石柱、木屋与收集玩法核心玩法围绕三根发光石柱展开。每根石柱是一个圆环加浮空的能量球当玩家靠近到1.5米内屏幕出现按E激活的提示按E键后石柱顶部的光球会发光并浮升同时周围3米内的雾被短时间驱散露出周边地形。雾的驱散实现方式很简单我没有真的去改变全局雾参数而是在石柱上方添加一个新的点光源强度高、衰减快把周围物体照得很亮。当物体被强光照射时雾色在混合比例中的权重就会降低视觉上就像雾被驱散了。这个技巧非常实用比动态调整fog.density要靠谱得多。木屋是游戏终点由几个BoxGeometry拼成加上斜面屋顶和一个小烟囱。位置固定在树林深处。由于雾的存在玩家只有通过石柱驱散带来的短暂视野才能确认木屋的方向。如果找不到也没关系游戏允许玩家无限次试错。3.4 碰撞检测为什么用最土的方案第一人称游戏最怕的就是穿模。本作里玩家会被树木、石头和木屋挡住。Three.js官方虽然没有内置碰撞检测但我们可以自己做。所有障碍物都是圆柱体或包围球的近似体碰撞检测的复杂度因此大幅降低。我用的碰撞方案是胶囊体简化版把玩家当成一个半径为0.3米的圆柱体检测玩家当前位置是否与障碍物的包围圈相交。实现很简单用向量距离和半径相加做判断function collide(pos, obstacles) { for (const obs of obstacles) { const dx pos.x - obs.position.x; const dz pos.z - obs.position.z; const minDist 0.3 obs.radius; if (dx * dx dz * dz minDist * minDist) { // 反向推挤玩家 const dist Math.sqrt(dx * dx dz * dz); pos.x obs.position.x (dx / dist) * minDist; pos.z obs.position.z (dz / dist) * minDist; } } }这套碰撞的精度并不高但胜在极快60个障碍物的场景里每帧检测的开销可以忽略不计而且不会出现卡墙里出不来的致命bug。因为每个障碍都用一个理想化的圆包围即使陷进去反向推挤也能把你弹出来这是复杂网格碰撞很难做到的。真正不建议一上来就做的东西是Three.js官方自带的Raycaster碰撞因为它太精准容易在棱角处突然卡住对玩家来说就是一顿一顿的不爽体验。4. 性能优化与移动端适配4.1 雾其实是个渲染优化利器这个标题可能有些反直觉。雾不只是视觉效果它也是一个渲染优化手段。因为FogExp2的存在远处的物体在像素层面就被雾色覆盖了GPU浪费的着色计算被减少。但真正性能收益还在别处我用了renderer.setClearColor(0xc8c8c8, 1)作为背景色配合雾效后场景边缘不需要渲染的天空纹理就自然完成了色彩过渡。树木的实例化方面我预计树林需要约200棵树但如果每棵树都用独立的Mesh渲染每帧就要提交200次绘制调用。浏览器中移动端的绘制调用上限约在300左右单个物体提交加材质切换很容易突破上限所以必须合并。具体方案是所有不参与交互的树在生成后调用geometry.merge合并成一个整体网格。合并后200棵树的性能开销瞬间变成了一次drawcall。唯一的代价是合并后无法单独对某棵树做变换或碰撞检测所以我保留了每棵树的位置信息用于碰撞检测而渲染时用的是合并后的模型。实测下来在低端笔记本上帧率从45提升到60移动端也能稳定在28到30帧对Web虚拟环境来说足够。4.2 移动端的虚拟摇杆做移动端适配第一是触屏事件替代鼠标事件第二是虚拟摇杆。我加了一个非常朴素的方案屏幕左下角的一个圆底玩家手指放上去滑动就根据偏移量生成移动方向。判断逻辑也很直观监听touchstart记录触点坐标touchmove时用当前坐标减初始坐标求向量把这个向量换算成方向输入。UI上只有两个Div嵌套外层是摇杆底座内层是活动圆点style中做边界限制就行。手指抬起时要把移动向量清零否则玩家的角色会一直向最后的方向漂移这个问题是手机端最常见的bug。我在开发早期踩了这个坑——手上拿着手机松手后角色还继续跑画面就飘出去了。4.3 移动端性能的另两个杀手手机端的第一个性能杀手是像素比。默认情况下Three.js会把canvas的绘制分辨率设置成CSS像素尺寸但iPhone和安卓旗舰机的设备像素比都在2到3之间。如果允许3倍增分辨率游戏的实际像素将是CSS像素的9倍手机GPU根本扛不住。一定要限制像素比上限renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));限制到2能保住手机画质的清晰度同时让性能不至于崩。第二个杀手是阴影。在《迷雾》里我直接禁用了全部动态阴影。原因是阴影贴图在移动端代价太高更关键的是我这个游戏的视觉风格偏扁平朦胧光照依赖雾色混合与粒子特效来塑造没有影子反而更像游戏独有的白色宇宙风格。5. 常见问题排查与工具避坑5.1 指针锁定在iframe里失效如果你把游戏嵌入到其他网站的iframe里鼠标锁定功能会不生效。这是浏览器安全性问题iframe必须拥有allowpointer-lock属性才能申请锁定。即使把游戏部署到自己的服务器只要外层套了iframe就得记得在iframe标签上加这个属性。如果是开发时直接在本地双击打开index.html需要注意requestPointerLock()必须被用户手势触发。也就是说必须在鼠标点击的回调里调用不能放到setTimeout或onload里否则会被浏览器拦截。5.2 雾不显示或颜色发灰的排查思路游戏中有雾但看不见多半是背景色出了问题。Three.js的默认场景背景色是黑色的如果设置了雾但没设置scene.background那远处就是黑色雾混着黑色会显得像被烟熏过。解决办法是把背景色设成和雾色一致或者干脆不设置背景让雾去统一一切。另一个常见坑是雾被透明材质的物体打断。如果你用了transparent: true的材质Three.js的渲染顺序会把透明物体单独排队在它们之后雾效可能不会正常应用。我建议游戏里所有材质都保持opacity: 1即使想用半透明效果也尽量通过纹理的alpha通道来实现。5.3 碰撞抖动的解决办法碰撞反向推挤法有个副作用物体靠近时如果每帧推挤幅度稍大就会在边界处来回抖动视觉上像是人物在呼吸。我把推挤改成插入距离补偿模式精确计算穿透深度一次推到位同时加入了一个缓存变量记录上次的位置如果穿透深度小于一个极小阈值就忽略。这样抖动的问题彻底消失。6. 项目延伸如何迁移到Vue3加TS的项目里6.1 三个猜想从游戏到可视化系统的迁移路线做完《迷雾》后我最直接的感受是这套代码完全可以迁到企业级的3D可视化项目里比如Vue3 TypeScript的机房可视化系统。怎么迁移核心思路是实例化场景部分完全解耦。我的world.js已经做成了一个接收THREE.Scene参数的纯函数只要传入不同的场景数据就能生成不同的虚拟环境。迁移到Vue3项目中时我只需要把createWorld改成异步生成返回Promise配合组件的onMounted然后把玩家控制逻辑挪到composable里用ref管理游戏状态。TypeScript的重写也顺利。关键是对Three.js的矢量、矩阵类型做精确标注大部分工作其实就是给已有JS函数加上参数类型接口。Three.js官方给类型支持做得很好types/three包几乎覆盖全部API。真正需要额外设计的是与UI框架的通信。游戏在运行时频繁更新摄像机位置但业务系统只需要知道玩家进入了哪个机柜区域。所以我会用事件订阅机制游戏引擎往外部抛出进入房间、离开区域等事件业务层接收后更新Vue的响应式数据。6.2 从Three.js游戏到URDF机器人展示顺便提一下搜索热词里出现的three.js urdf-loaders。URDF是机器人领域的标准模型描述格式用于描述机器人的关节、连杆、碰撞体。Three.js有对应的URDF加载器可以在网页里加载一个机械臂模型通过滑块控制各个关节角。这看起来和《迷雾》完全不相干但底层逻辑高度相似都是加载场景数据设置相机处理交互输入更新渲染循环。区别只在于游戏里物体的位置由玩家控制而机器人展示里物体的角度由滑块控制。一旦你熟悉了Three.js的核心渲染思路转向URDF这类加载器也就是半天的事。6.3 雾、灯光和Shader的进一步玩法《迷雾》里的雾应用了FogExp2但如果你想做更高级的体积雾也就是那种雾在空中翻涌的效果就得写自定义Shader了。思路比较简单在后处理阶段做一次全屏Shader把深度和摄像机位置做差算出空气的遮挡度再乘上一个噪声贴图让雾的浓度随空间变化。这个方案几乎支撑所有雾型游戏的视觉巅峰但这种后处理在移动端是不现实的。移动端更加靠谱的选择是你可以在原有FogExp2基础上给雾coverage层增加动态干扰——使用Sin函数调节密度勉强有流动感。这个效果我在PC端的扩展版本里加了效果还不错。移动端为了帧率我就关掉了。7. 最后分享几点掏心窝的实战体会做这个项目的过程中我最大的感悟是技术方案的选择不取决于它的知名度而取决于你的核心诉求。《迷雾》要的是打开即玩、轻量、沉浸氛围所以Three.js 原生JS就是最佳组合而不是Unity或Unreal。就算后期要迁移到Vue3 TS底层学习的也是同一个心智模型——场景树、渲染循环、碰撞、灯光。这些底层能力不会因为框架切换而消失。如果你想复现这个项目我建议你按照这样的顺序来做先把场景和相机架起来确认鼠标指针锁定能工作再加雾和各材质然后加入移动和碰撞最后才去做石柱、木屋和交互。一次只做一件事而不是上来就造一个庞大的场景。这样每一步的bug都缩小在小范围内你不会陷入问题出在哪的无底洞。第一人称游戏的精髓不是炫目的画面而是稳定连续的操作反馈和空间认知的建立。玩家在雾里走两步就被挡住看不清世界是碎的这种感觉比任何画面Bug都致命。还有一个我至今受用的小技巧调试雾参数时不要反复改代码再刷新浏览器这样效率太低。直接在console里动态修改scene.fog.density和灯光的intensity实时预览变化找到合适值再写回代码。配合Three.js的dat.GUI调试面板这个项目的大部分视觉调优都变得一目了然强烈推荐。《迷雾》后续我可以做的方向还有很多比如加一个计时模式玩家在限定时间内找到木屋或者加入随手可得的火柴点燃沿途的蜡烛以扩展视野等级再或者加入动态天气循环雾由浓转淡再由淡转浓让玩家不得不等待合适的探索窗口。每一个想法都不复杂但都直接依托于现成的系统基础。这种项目的价值正是如此——一个好的引擎底座能撑起的玩法远比预想的多。