这个项目是我用三周的业余时间捣鼓出来的名字就叫《迷雾》。想法很简单做一个第一人称探索小游戏玩家被丢进一片浓雾笼罩的荒野手里没有武器只有方向键和一双眼睛四周能见度压到十米以内远处隐隐约约能看到灯光和石柱你要在雾中找到散落的信标依次点亮它们才算通关。整个游戏跑在浏览器里打开网页就能玩不用下载任何安装包。为什么敢用Three.js做游戏而不是直接拉一套Unity或Godot这一篇我会把这二十多天里踩过的坑、试过的参数、推翻过的方案完整讲一遍从场景骨架、第一人称控制、雾效实现到随机地图生成和部署上线的全过程。如果你也想用纯网页技术做一版有氛围感的3D小游戏或者对Three.js的雾效、碰撞检测这类细节感兴趣这篇应该能省下你不少摸索时间。1. 为什么我用Three.js而不是Unity去做《迷雾》——选型逻辑与项目定位先交代一下项目背景。《迷雾》的核心玩法说白了就三件事行走、观察、交互。地图是一块大约一百米见方的荒原零散分布着十几根石柱、几片枯树、一座坍塌的围墙玩家要在雾里来回穿梭找到五个信标并逐个激活。听起来简单但对场景氛围的要求很高——雾必须浓到让人有压迫感又不能浓到完全瞎走信标发出的光要穿透迷雾成为玩家的方向指引运动手感要顺滑不能用帧率波动把玩家晃晕。我一开始确实纠结过要不要上Unity。Unity做第一人称探索有天然优势角色控制器现成PhysX物理碰撞开箱即用Lighting和Post-processing一套组合拳下来雾气和体积光做得又快又好看。但我认真想了一天之后还是决定用Three.js理由非常务实不需要安装任何IDE和SDK。Unity新建项目要先跑一遍几GB的下载而Three.js只需要一个HTML文件加一个CDN链接双击就能打开。代码控制力足够强。第一人称的移动、碰撞、雾效浓度变化这些对Unity来说是引擎帮你做对Three.js来说是你自己写。写完之后你对WebGL坐标系、渲染管线的理解会深一大截这个收获是Unity的拖拽式编辑给不了的。部署成本几乎为零。Unity的WebGL导出包动辄几十MB加载还慢Three.js打包后只有几MB放到任意静态托管平台就能跑。审美可控。我想做的不是写实场景而是风格化的低多边形加雾气。Three.js底层的Shader和相机控制可以逐帧调整更容易做出我想要的感觉。当然Three.js也不是没有短板。没有内置的物理引擎碰撞检测得自己写没有场景编辑器和行为树所有物件坐标都在代码里生成音频管理、资源加载、移动端适配也都是零起步。所以这个方案更适合小规模、强氛围、快迭代的独立项目——《迷雾》正好属于这一类。你要是打算做大型开放世界那还是老老实实用引擎别跟自己过不去。2. 场景骨架搭建渲染器、相机和坐标系的几个关键决定选定Three.js之后第一件要做的是搭骨架。这部分看似基础但其中几个决定会直接影响后面所有的代码走向我分开说。2.1 渲染器与色彩空间别让雾的颜色发灰我用的渲染器初始化代码大概是这样的const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace THREE.SRGBColorSpace; renderer.shadowMap.enabled true; renderer.shadowMap.type THREE.PCFSoftShadowMap; document.body.appendChild(renderer.domElement);这里有两个坑是新手容易踩的。第一个是renderer.outputColorSpaceThree.js从r152版本开始默认输出色彩空间改为SRGBColorSpace如果你用的是旧教程的代码会发现所有颜色都偏灰、雾的颜色怎么调都不对。第二个是setPixelRatio——移动端很多屏幕的devicePixelRatio高达3或4如果直接用原始值抗锯齿没问题但GPU压力直接拉到七八倍手机发烫掉帧是必然的。我实测在2K屏上把pixelRatio从3压到2帧率能从45帧升到75帧画面差距肉眼几乎看不出来。2.2 相机与视野角为什么第一人称不用正方体摄像机热词里有个three.js正方体摄像机效果说的是有人用BoxGeometry做摄像机效果或者用正交相机实现像素风剪影。我在试验阶段也试过正交相机思路是让画面保持像纸片一样的扁平感配合浓雾做舞台剧式氛围。试了两晚之后放弃了原因很简单正交相机没有近大远小在雾里判断距离全靠雾的浓度一旦浓度波动玩家会觉得地面在呼吸非常出戏。最终我用的是透视相机参数如下const camera new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 300); camera.position.set(0, 1.6, 0);视野角75度是为了增强沉浸感和速度感。近裁剪面0.1是为了当玩家贴墙时不会出现物体被裁掉的穿墙透视现象。远裁剪面300米看着很大但因为有雾超过50米的东西本来就看不见了所以这个值其实很宽裕。三个参数里我建议你一定要仔细调的是近裁剪面——它越小近景物体越容易在边缘产生透视拉伸感但如果你调到0.01远处反而容易看到z-fighting抖动。0.1是测试过很多次的平衡点。2.3 坐标系约定Y轴是上Z轴是前Three.js的坐标系是右手坐标系X向右Y向上Z指向屏幕外。对第一人称游戏来说这意味着相机放在y1.6这是人的眼睛高度地形放在y0的平面上玩家前进的前方向就是相机朝向的投影方向Z轴正方向。这个坐标约定看起来很简单但到了写移动逻辑时非常容易绕晕尤其是鼠标旋转后的方向计算。我的建议是一开始就在场景里放一个AxesHelper。const axesHelper new THREE.AxesHelper(5); scene.add(axesHelper);红绿蓝三根线分别对应X、Y、Z轴跑起来能看到角色是朝哪个方向走的。游戏正式发布前再把这个helper删掉就行。这个小东西帮我避开了至少两个小时的坐标系脑补时间。3. 第一人称手感指针锁定、WASD移动和碰撞检测的实战细节骨架搭好之后游戏好不好玩就看操作手感了。第一人称操作的核心就三件事鼠标控制视角、键盘控制位移、防止穿墙。每一件都藏着细节。3.1 鼠标视角用PointerLockControls还是自己算Three.js官方examples里有个现成的PointerLockControls它解决的问题是鼠标移动换算成旋转。我在项目里直接用了它button idstart点击进入迷雾/buttonimport { PointerLockControls } from three/examples/jsm/controls/PointerLockControls.js; const controls new PointerLockControls(camera, renderer.domElement); document.getElementById(start).addEventListener(click, () { controls.lock(); }); controls.addEventListener(lock, () { document.getElementById(start).style.display none; }); controls.addEventListener(unlock, () { document.getElementById(start).style.display block; });这里要重点说一件事PointerLockControls只负责鼠标旋转不负责移动。你必须在自己的更新循环里读取相机方向计算出WASD对应的位移向量然后移动相机。我见过不少新手在这卡住以为加上controls就能自动走路其实不是。移动的核心逻辑长这样const keys { w: false, a: false, s: false, d: false }; window.addEventListener(keydown, e { if (e.code.startsWith(Key)) keys[e.code.toLowerCase().replace(key, )] true; }); // 对应的keyup也要写这里省略 function updateMovement(delta) { const speed 4.2; // 米/秒步行速度 const forward new THREE.Vector3(); camera.getWorldDirection(forward); forward.y 0; forward.normalize(); const right new THREE.Vector3(); right.crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize(); const move new THREE.Vector3(); if (keys.w) move.add(forward); if (keys.s) move.sub(forward); if (keys.a) move.sub(right); if (keys.d) move.add(right); if (move.lengthSq() 0) { move.normalize().multiplyScalar(speed * delta); // 碰撞检测后再真正改变位置 tryMoveXZ(move); } }有个细节值得说forward.y 0这一步非常关键。如果直接拿相机世界方向向量抬头看天时水平移动就会变慢甚至原地打转把这个向量的Y轴清零再normalize就能保证视线朝上看时走路依然平行于地面。3.2 碰撞检测两个方向的取舍Three.js没有内置的碰撞检测最常见做法有两种一种是用Raycaster从玩家中心朝移动方向打射线如果距离小于半直径就判定碰撞另一种是规则网格碰撞把地图抽象成一个二维数组0是空地1是障碍物移动前检查目标格子是否可行走。我两个都试过。Raycaster胜在通用性强适合任意形状的障碍物但它的执行成本比纯数学判断高很多——每帧要打射线场景里物体多了还会出现射线与网格交叉的抖动问题。网格碰撞则牺牲了一点真实感换来的是稳定和高效。《迷雾》的地图本质是一张64x64的二维网格石柱、残墙、枯树都落在这个网格上所以用网格检测最合适function isWall(x, z) { const gx Math.round(x); const gz Math.round(z); if (gx 0 || gz 0 || gx MAP_SIZE || gz MAP_SIZE) return true; return mapData[gz][gx] 1; } function tryMoveXZ(move) { const newX camera.position.x move.x; const newZ camera.position.z move.z; if (!isWall(newX RADIUS, camera.position.z)) camera.position.x newX; if (!isWall(camera.position.x, newZ RADIUS)) camera.position.z newZ; }这里的RADIUS我用了0.3米相当于把人看作一个半径30厘米的圆柱。X轴和Z轴分开检测的好处是撞墙时角色可以沿墙滑动不会像撞到空气墙一样卡死。3.3 手感调整的两个真坑第一个坑是帧率不稳定导致的位移跳变。如果直接用requestAnimationFrame的回调时间戳去算位移60帧和30帧的移动速度会差一倍。解决办法是用THREE.Clock获取delta时间并且给delta设一个上限——防止玩家从后台切回来时一帧就跳出去几十米const clock new THREE.Clock(); const delta Math.min(clock.getDelta(), 0.05);第二个坑是Pointer Lock退出后的交互残局。鼠标锁定状态下点击屏幕会重新锁定但如果玩家按Esc退出锁定界面上的点击进入迷雾按钮必须重新浮现否则游戏会变成鼠标永远消失、怎么点都没反应的死活局。我把lock和unlock事件都绑成对应函数后这个问题才算彻底解决。建议你把这段交互逻辑放在一个独立的UI控制器里别和游戏渲染循环混在一起。4. 雾效实现与参数调优从THREE.Fog到FogExp2再到动态迷雾机制《迷雾》的核心氛围就看雾这也是整个项目在技术上最值得写透的部分。Three.js里雾效主要有三种做法Fog线性雾、FogExp2指数雾、以及后期Shader效果器。前两种是内置的第三种是自己写图形程序的重度定制方案。我先说内置方案。4.1 线性雾和指数雾的区别// 线性雾在near到far之间线性增加浓度 scene.fog new THREE.Fog(0x0d1a2a, 10, 45); // 指数雾从相机位置开始按指数衰减density越大雾越浓 scene.fog new THREE.FogExp2(0x0d1a2a, 0.035);线性雾的物理含义是雾从10米开始逐渐变浓到45米完全挡住视线。指数雾则是每单位距离雾的浓度乘以一个指数系数它更接近真实雾气在大气中的衰减规律。我最初用的是线性雾参数是(8, 40)能见距离正好覆盖两三个石柱之间的间隔。但玩了几轮后发现一个不自然的现象在8米之内物体完全清晰到8米突然开始模糊边界感很强。换成FogExp2之后雾的浓度从脚下就开始累积那种你看不清远处但能隐约感知轮廓的感觉自然多了。这里有个重要概念雾和物体的材质颜色是相乘的关系。如果你设了场景背景色和雾的颜色一致那么被雾完全遮挡的远处物体就会溶入背景这是营造无边界荒原的秘诀。我把雾的颜色设成深蓝灰色0x0d1a2a同时把场景背景也设成同一个颜色画面远处的天与地就没有明显接缝非常干净。4.2 让雾活起来动态浓度与粒子飘移静态的雾玩久了你就会发现一个问题它更像是距离滤镜而不是雾气。因为真正的雾会呼吸、会飘动、时浓时淡。我给雾加了一个动态浓度值let mistIntensity 0.035; function updateFog(time) { const breathing Math.sin(time * 0.15) * 0.006; const base 0.032 breathing; // 根据玩家距离信标的远近动态调整 if (nearBeacon) { mistIntensity THREE.MathUtils.lerp(mistIntensity, base 0.012, 0.02); } else { mistIntensity THREE.MathUtils.lerp(mistIntensity, base, 0.01); } scene.fog.density mistIntensity; }这个设计在玩法上的效果非常明显靠近信标时雾气变浓玩家在最后几步几乎什么都看不见只能靠着光晕的方向摸索。那个紧张感玩过的朋友都说绝了。如果你想更进一步可以叠一层粒子飘雾。做法是用THREE.Points生成几百个稀疏的半透明小粒子让它们沿着XZ平面缓慢漂移模拟雾气团块在风中流动。粒子数量控制在300以下Texture用一张Canvas画的径向渐变圆不需要额外贴图文件性能也很稳。这算是热词里雨雪雾怎么实现给到我的启发——雨和雪本质上也是粒子系统只是运动方向不同。4.3 体积雾为什么不继续深挖标准Fog方案做的是平面雾——雾的浓度只和距离有关和高度无关。真正写实的体积雾会考虑低洼地带雾气更浓山腰处雾被风吹散等立体空间变化这需要用光线步进或depth-based fog的后期Shader来处理。我在开发中其实试过一次shader方案用的是three.js官方示例里的webgl_shader_fog改出来的版本。效果确实惊艳雾有了体积感但代价是每帧的计算量上去了低端手机上帧率掉到25以下。在坚持用最简方案实现氛围感的前提下我最后没有把体积雾放进主线版本。这个决定帮我保住了一个流畅的体验也让你在复现这个项目时不需要事必躬亲地写几百行GLSL。5. 随机场景生成与游戏循环让探索在每个存档点都成立雾再好看场景是空的也没法玩。《迷雾》的地图既要随机生成让每次打开都有新鲜感又要保证信标位置和行走路径是合理的。5.1 固定种子随机与地图网格我用了一个带固定种子的伪随机函数比如最广为流传的mulberry32function mulberry32(seed) { return function () { seed | 0; seed seed 0x6D2B79F5 | 0; let t Math.imul(seed ^ seed 15, 1 | seed); t t Math.imul(t ^ t 7, 61 | t) ^ t; return ((t ^ t 14) 0) / 4294967296; }; }生成流程分四步用种子随机生成64x64数组约30%格子标记为障碍。用简单的连通性算法我是从地图中心随机向外扩散替代严格完美迷宫算法保证地图不存在完全被围死的大片区域。把五位信标分别放在距中心不同方向的位置确保玩家从任何起点出发都有一条不太绕的路线。在障碍格上放置石柱、枯树、残墙模型。用固定种子的好处是我能在本地先跑几百次生成、检查路径连通性再决定用哪一批种子作为正式地图。否则每次刷新都重新随机很可能出现一把能解下一把无解的尴尬。5.2 照亮雾中世界灯光的取舍雾的浓度高时DirectLight和AmbientLight的配比要重新调。我一开始沿用白天场景的方式放了AmbientLight加DirectionalLight结果雾中所有物体都灰蒙蒙的毫无层次。后来改成三灯方案一盏低强度的AmbientLight#223344亮度0.35负责保证最暗处不至于全黑一盏DirectionalLight#ffeedd亮度1.2位置在(10, 15, 6)负责模拟从雾上方漏下的日光每盏信标灯用PointLight加辉光球体负责制造迷雾深处的希望。信标灯是核心视觉锚点。我将它做成一个半径0.5米的发光球体材质用MeshBasicMaterial——因为它不受光照影响总是以自身的颜色发光。再叠加一个PointLight色温设为暖黄强度在1.8左右。当玩家距信标还有二十米时透过浓雾能看到一个模糊的光晕距离越近光晕越强直到玩家伸手可触。const beaconMat new THREE.MeshBasicMaterial({ color: 0xffaa33 }); const beaconMesh new THREE.Mesh(new THREE.SphereGeometry(0.5, 16, 16), beaconMat); const beaconLight new THREE.PointLight(0xffaa33, 1.8, 20, 2); beaconMesh.add(beaconLight);5.3 游戏循环、目标判定与循环张力游戏循环用requestAnimationFrame包一层let elapsed 0; let activeBeacons 0; function animate() { const delta Math.min(clock.getDelta(), 0.05); elapsed delta; updateMovement(delta); updateFog(elapsed); updateBeaconCollision(); updateDeadEndHint(); renderer.render(scene, camera); requestAnimationFrame(animate); } animate();updateBeaconCollision每次循环检查玩家与五个信标的位置距离。当距离小于1.2米时信标被激活记录触发时间并让信标亮度脉冲一次然后刷新当前信标索引引导玩家去下一个位置。如果玩家很久没找到一个信标updateDeadEndHint会根据当前时间和信标的相对位置在屏幕角落显示一个方向箭头用CSS定位一个div不占三维场景。这个循环设计的核心张力在于雾的浓度是不停细微变化的但信标光的方向始终可辨。玩家的策略因此变成一个持续的决策过程——我该相信刚才闪过的那团暖光还是回头探索另一片没走过的空地5.4 性能控制合并几何体与阴影范围场景里的石柱、树桩如果一个个加200个模型就是200次draw call手机直接阵亡。我的做法是石柱用圆柱体树桩用圆柱体加少量圆锥全部用InstancedMesh批量实例化const columnGeo new THREE.CylinderGeometry(0.4, 0.5, 2.5); const columnMat new THREE.MeshStandardMaterial({ color: 0x666677 }); const columns new THREE.InstancedMesh(columnGeo, columnMat, 40);阴影贴图尺寸限制为1024x1024且只有DirectionalLight开阴影信标的PointLight不开阴影只负责染色粒子系统用points单批次渲染。这几招做完整场draw calls从400次降到不足70次中端手机稳在60帧低端手机也能跑到30帧以上。6. 部署、调试与兼容性从本地到纯网页可玩的最后一公里游戏开发完本地能跑只是第一步。标题里写着纯网页可直接游玩这句话意味着部署方案要有一些讲究。6.1 静态托管与构建我的项目用Vite做开发服务器和构建工具。开发时命令是npm run dev自动打开浏览器、热更新发布时用npm run build它会将源码打包到dist目录这个目录里的所有文件都是纯静态的。我把dist目录扔到了GitHub Pages和Netlify两个平台。两者的区别GitHub Pages适合零成本托管但需要仓库名匹配你的用户名.github.io路径Netlify支持拖拽上传文件夹还能自动识别SPA路由对项目里含离线页面的人更友好。实际部署时有个小坑音量自动播放策略。如果你在游戏中加入背景音乐浏览器会因为用户的交互策略Autoplay Policy自动拦截音源。我最后在点击进入迷雾按钮里绑定一次音频播放作为解锁音的画布。这样玩家点击进入后后续的声音播放就畅通了。const ctx new (window.AudioContext || window.webkitAudioContext)(); startButton.addEventListener(click, () { ctx.resume(); controls.lock(); });6.2 兼容性与降级多数桌面浏览器对WebGL的支持都很好。我实测了Chrome、Firefox、Edge和Safari桌面版帧率都在可玩范围。但有一个你必须有心理准备的点iOS端不支持Pointer Lock也就是说手机上没法用鼠标视角。我没有时间做完整的虚拟摇杆交互所以做了一个简单但可行的降级方案检测当前设备是否有pointerlockerror或dozens不支持就在界面上提示本游戏最佳体验为桌面浏览器移动端强制使用竖屏模式并添加两个屏幕边缘的触摸滑动区——左半边控制移动方向相对屏幕方向右半边控制视角旋转。由于是纯JS触摸事件区分没有依赖Pointer Lock API所以小程序或者说H5容器也能勉强跑起来。这个降级方案在手机上看肯定谈不上精致但至少没有让人一点都玩不了。如果你想要完整的移动端适配虚拟摇杆那一块值得投入更多时间——这是我在该项目里认为最该后续补充的部分。6.3 调试工具别再靠console.log猜了开发阶段最常遇到的是相机瞬间飞到地图外雾的颜色不对信标光晕缺失这类问题。有个思路可以大幅减少调试成本在场景里放一个Stats.js性能面板实时查看帧率和draw calls用dat.GUI现在的lil-gui把雾浓度、移动速度、光强度这几个常调参数做成滑块场景里的所有边界物体临时染成高亮色方便在雾中定位。我个人强烈建议你在正式版本里也保留一个隐藏的调试入口比如双击屏幕触发GUI这样以后游戏想调整参数时就不用重新打包部署——我正是因为吃够了本地改参数线上没生效的苦才把调试面板留在了游戏里的。实践下来这个决定让调参和试玩真正变成了同一件事。最后再分享一个我在这项目里收获最大的心得。当初在Three.js和Unity之间反复犹豫时一位朋友劝我如果你只是想学会怎么用3D引擎做游戏那你就去学引擎但如果你想知道3D游戏在原理上到底是怎么跑起来的你就该用裸库做一次。三周时间下来我确实把雾效、碰撞、相机、场景管理这些底层逻辑都过了一遍现在回头再去看Unity的手册很多当初看不懂的选项都变成了哦原来它封装的是这个东西。《迷雾》这个项目不算大但它帮我建立了一套从底层看游戏的思维方式。如果你也想试试从Three.js的官方examples和这篇里的代码出发搭一个自己的小场景应该不会太难。希望这篇经验能帮你少踩几个坑。拿起代码的时候别忘了调一下雾的浓度——它才是让一切变得神秘的关键。