☰
三天用Three.js撸了个3D母校:vibe coding实战
2026/10/29 7:26:10 网站建设 项目流程

1. 从一句“vibe coding”说起:我为什么用三天撸了个3D母校

开学季那阵子,朋友圈里全是新生拖着箱子进校门的照片,我翻着翻着突然冒出一个念头:能不能用代码把母校最标志性的那栋主楼和校门做成一个能转、能走、能看的3D网页?不是那种贴几张照片的H5,而是真正用几何体堆出来的、能在浏览器里实时渲染的3D场景。刚好那段时间“vibe coding”这个词特别火,意思大概就是跟着感觉走、边写边调、不追求一开始就架构完美,先把东西跑起来再说。我决定就用这种状态来干这件事——不画原型图、不写详细设计文档,打开编辑器就开干。

这个项目的核心其实就三件事:用Three.js在网页里搭出3D场景,用WebGL做底层渲染,用Node.js起一个本地服务方便调试和后续部署。听起来简单,但真做起来,从建模思路到贴图加载,从光照调到性能优化,坑一个接一个。我前后大概花了三个晚上加一个周末,最终做出来一个能在浏览器里自由旋转、缩放、带昼夜切换的3D母校场景,主楼、校门、操场、旗杆都在,虽然精度比不上专业建模软件出的效果,但胜在纯代码生成、体积极小、加载飞快。

这篇文章适合谁看?如果你有一点前端基础,听说过Three.js但没真正上手做过完整项目,或者你想用代码做点有纪念意义的东西,那这篇应该能帮到你。如果你完全没接触过3D编程,也没关系,我会把每个关键步骤拆开讲,包括我踩过的坑和最后怎么绕过去的。整个项目不依赖任何付费工具,全部用开源库和免费资源完成,你跟着做一遍,大概率能做出一个属于自己的版本。

2. 整体思路与技术选型:为什么是Three.js而不是Blender导出

2.1 核心需求拆解:我要的到底是什么

先想清楚需求,再选工具,这是我踩过很多次坑之后养成的习惯。这个项目的核心需求可以拆成四条:第一,场景要能在浏览器里直接打开,不需要装任何插件;第二,模型要能自由旋转和缩放,最好还能用键盘或鼠标控制视角移动;第三,要有基本的光照和阴影,不然看起来就是一堆灰色方块;第四,整个项目的体积要小,加载要快,不能打开等半天。

基于这四条,我排除了几个方案。用Blender建模然后导出glTF文件当然可以,效果也好,但问题是我不太会用Blender,而且导出文件动辄几MB甚至几十MB,加载速度不理想。用Unity做WebGL导出也行,但打包体积更大,而且对我来说太重了。最后选Three.js,原因很简单:它就是一个JavaScript库,引入一个几百KB的文件就能用,几何体可以用代码直接生成,不需要外部模型文件,贴图也可以用Canvas动态画出来,整个项目可以做到几乎零外部资源依赖。

2.2 为什么用代码生成几何体而不是导入模型

这里要展开说一下。很多人做3D网页第一反应是找个模型网站下载一个glTF或OBJ文件,然后加载进去。这个思路没问题,但有几个现实问题:第一,免费模型的质量参差不齐,找到符合母校建筑风格的很难;第二,模型文件通常包含大量细节,面数很高,在浏览器里渲染压力大;第三,加载外部模型需要处理路径、跨域、格式兼容等一系列问题,对新手不友好。

用代码生成几何体的好处是:完全可控,想要什么形状就写什么代码,面数可以精确控制,不需要加载任何外部文件。坏处是复杂形状很难做,比如雕塑、树木、人物这些。但母校的主楼和校门本质上就是长方体、圆柱体、平面这些基本几何体的组合,用BoxGeometry、CylinderGeometry、PlaneGeometry完全够用。我实际做下来,主楼用了大约十几个BoxGeometry拼出主体和窗户,校门用了两个圆柱加一个横梁,操场就是一个大平面加几条线,整体面数控制在几千个三角形以内,渲染毫无压力。

2.3 Node.js在项目里的角色:不只是起个服务

很多人觉得Node.js在这个项目里就是起一个本地服务器,用npx serve或者http-server就行了。但我实际用下来,Node.js还能做更多事。第一,我用它写了一个简单的构建脚本,把分散的JS模块合并成一个文件,减少HTTP请求;第二,我用它做贴图的预处理,把一些需要动态生成的纹理提前用Node.js的Canvas库画好存成图片;第三,部署的时候用Node.js写了一个极简的静态文件服务,方便在本地网络里用手机也能打开看效果。

安装Node.js这块我踩过一个坑。当时看到网上有人说用最新版,我就去官网下了最新的24.x版本,结果跑某个依赖的时候报错说版本不兼容。后来换成LTS版本(当时是20.x)就一切正常。所以如果你也要做类似的项目,建议直接上LTS版本,别追最新。Ubuntu下安装可以用NodeSource的脚本,也可以直接用nvm管理多版本,我个人推荐nvm,切换版本方便,不会污染系统环境。

3. 核心细节解析:从零搭建一个能跑的3D场景

3.1 Three.js的引入方式与版本选择

Three.js的引入方式有好几种:可以直接下载three.min.js用script标签引入,也可以用npm安装然后打包,还可以用ES模块的方式从CDN引入。我一开始用的是CDN引入,简单直接,但后来发现CDN在某些网络环境下不稳定,加载慢甚至加载失败。后来改成把three.module.js下载到本地,用importmap的方式引入,这样既不需要打包工具,又不受网络影响。

版本选择上,Three.js的更新频率很高,新版本会废弃一些旧API。我建议选一个近半年内的稳定版本,不要用太老的教程里提到的r70、r80这种,那些版本的API和现在差别很大。我用的版本是r160左右,核心API比如Scene、PerspectiveCamera、WebGLRenderer、BoxGeometry、MeshStandardMaterial这些都很稳定,网上大部分教程也适用。

3.2 场景、相机、渲染器:3D世界的三件套

任何Three.js项目都从这三样东西开始。Scene是场景容器,所有物体都要加进去;PerspectiveCamera是透视相机,模拟人眼看到的近大远小效果;WebGLRenderer是渲染器,负责把场景画到Canvas上。这三者的初始化代码很固定,但有几个参数需要根据实际情况调整。

相机的fov(视场角)我设的是60度,这个值太小会显得视野狭窄,太大会有畸变。near和far分别是0.1和1000,决定了相机能看到的最近和最远距离。相机的位置我一开始放在(0, 5, 20),后来发现主楼比较高,需要把相机抬高到(0, 10, 30)才能看到全貌。渲染器的setSize要设置为窗口大小,并且监听resize事件动态调整,不然窗口变化时画面会拉伸变形。

这里有个细节:renderer.setPixelRatio(window.devicePixelRatio)这行代码很重要。如果不设置,在高分屏上画面会模糊;如果设置成2以上,性能又会下降。我实测下来,设置为Math.min(window.devicePixelRatio, 2)是比较平衡的选择。

3.3 用基本几何体拼出主楼和校门

主楼的结构其实不复杂:一个大的长方体作为主体,前面加几个小长方体作为突出部分,顶部加一个扁长方体作为屋檐,窗户用深色的小长方体贴在墙面上。关键是比例要协调,我一开始把主楼做得太宽太矮,看起来像个仓库,后来调整了长宽高比例,参考了母校主楼的实际照片,才看起来像那么回事。

校门更简单:两根圆柱作为门柱,一根横梁连接,横梁上可以用Canvas画上校名文字,然后作为贴图贴上去。这里有个技巧:用Canvas画文字然后转成纹理,比直接加载字体文件再渲染要简单得多,而且效果完全够用。操场就是一个大平面,用PlaneGeometry加一个绿色的MeshStandardMaterial,再画几条白线作为跑道。

注意:用代码生成几何体时,坐标系是Y轴向上,X轴向右,Z轴朝向屏幕外。放置物体时一定要想清楚每个物体的位置,不然很容易出现物体重叠或者悬空的情况。

3.4 光照与阴影:让场景从“纸片”变“立体”

没有光照的3D场景就是一堆纯色色块,完全没有立体感。Three.js支持多种光源:AmbientLight环境光提供基础亮度,DirectionalLight平行光模拟太阳光,PointLight点光源模拟灯泡。我用了环境光加平行光的组合,环境光强度设0.4,平行光强度设1.2,平行光的位置放在(50, 100, 50),模拟太阳从斜上方照射。

阴影是提升真实感的关键。开启阴影需要三步:渲染器的shadowMap.enabled设为true,平行光的castShadow设为true,物体的castShadow和receiveShadow分别设为true。但阴影很吃性能,尤其是阴影贴图的分辨率。我一开始设了4096,结果帧率掉到20以下,后来降到2048就流畅了。另外,平行光的阴影相机范围要设置好,shadow.camera.left/right/top/bottom要覆盖整个场景,不然阴影会被裁切。

4. 实操过程:从零到能跑起来的完整步骤

4.1 环境准备:Node.js安装与项目初始化

第一步是装Node.js。Windows和macOS直接去官网下载LTS版本的安装包,一路下一步就行。Ubuntu下我推荐用nvm安装,命令是curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash,然后nvm install 20,再nvm use 20。装完之后用node -v和npm -v验证一下,能输出版本号就说明成功了。

项目初始化很简单,新建一个文件夹,在里面执行npm init -y生成package.json,然后npm install three安装Three.js。如果你不想用npm,也可以直接下载three.module.js文件放到项目里。我两种方式都试过,npm的好处是版本管理方便,坏处是node_modules文件夹很大;直接下载文件的好处是干净,坏处是更新麻烦。对于这种小项目,我建议直接下载文件,整个项目文件夹不超过2MB。

4.2 搭建HTML骨架与引入Three.js

HTML文件的结构很简洁:一个canvas元素作为渲染目标,一个script标签引入Three.js,一个script标签写自己的代码。如果用importmap的方式,需要在script标签里声明模块路径。我实际用的结构是这样的:先引入three.module.js,然后在自己的代码里用import * as THREE from 'three'。这样写的好处是代码组织清晰,坏处是需要浏览器支持importmap,不过现在主流浏览器都支持了。

CSS部分只需要把body的margin设为0,canvas设为block,防止出现滚动条。另外我加了一个loading提示,因为场景初始化需要一点时间,虽然很短,但用户体验上有个过渡会更好。

4.3 编写场景初始化代码

场景初始化的代码我分成了几个函数:initScene()负责创建场景、相机、渲染器;createBuilding()负责生成主楼;createGate()负责生成校门;createGround()负责生成地面;createLights()负责光照;animate()负责渲染循环。这样拆分的好处是每个函数职责单一,调试的时候容易定位问题。

渲染循环用requestAnimationFrame实现,每一帧调用renderer.render(scene, camera)。如果场景是静态的,其实不需要每帧都渲染,可以用renderer.render只在需要的时候调用,节省性能。但我加了自动旋转功能,所以还是用了循环。自动旋转的实现很简单:在animate函数里让相机的角度每帧增加一点点,然后用camera.position.x = Math.sin(angle) * radius和camera.position.z = Math.cos(angle) * radius更新位置,最后camera.lookAt(0, 5, 0)让相机始终看向场景中心。

4.4 贴图与材质:让建筑看起来不那么“塑料”

纯色材质看起来很像塑料玩具,加上贴图会真实很多。但贴图加载有个常见问题:贴图开始不显示,过一会儿才出现,或者干脆不显示。这个问题的原因通常是贴图是异步加载的,而渲染循环在贴图加载完成之前就已经开始了。解决办法有两种:一种是用TextureLoader的onLoad回调,在贴图加载完成后再开始渲染;另一种是先用纯色材质,等贴图加载完成后再替换材质。我用的第二种,体验更平滑。

贴图的来源我用了两种:一种是程序生成的,比如用Canvas画砖墙纹理、画窗户;另一种是从免费纹理网站下载的,比如草地、天空。程序生成的好处是体积小、可控,坏处是细节不够丰富。我实际做下来,主楼的墙面用程序生成的砖墙纹理就够了,草地用下载的纹理效果更好。

提示:Three.js的纹理默认是翻转的,如果贴图显示上下颠倒,设置texture.flipY = false即可。另外,纹理的wrapS和wrapT要设为THREE.RepeatWrapping,这样贴图才能平铺。

4.5 交互控制:让用户能自由探索

Three.js自带的OrbitControls是实现旋转、缩放、平移的标准方案。引入方式是从three/addons/controls/OrbitControls.js导入,然后new OrbitControls(camera, renderer.domElement)。几个关键参数:enableDamping设为true可以让旋转有惯性,手感更好;dampingFactor设0.05左右比较合适;minDistance和maxDistance限制缩放范围,防止用户把相机拉到地底下或者拉到无穷远;maxPolarAngle限制垂直旋转角度,防止看到场景底部的空白。

除了OrbitControls,我还加了一个键盘控制:按WASD可以平移相机,按Q和E可以升降。实现方式是监听keydown和keyup事件,记录按键状态,在animate函数里根据按键状态更新相机位置。这个功能不是必须的,但加上之后探索感更强。

5. 常见问题与排查技巧实录

5.1 贴图不显示或显示异常

这是最常见的问题,我至少遇到了三次。第一次是贴图路径写错了,浏览器控制台会报404,这个好解决,检查路径就行。第二次是贴图加载了但显示为纯黑色,原因是光照不足,材质接收不到光,解决方法是增加环境光强度或者把材质的emissive设一个基础色。第三次是贴图显示但颜色不对,原因是色彩空间设置问题,Three.js的渲染器默认输出sRGB,但纹理默认是线性空间,需要设置texture.colorSpace = THREE.SRGBColorSpace。

还有一个隐蔽的问题:如果贴图的尺寸不是2的幂次方(比如300x300),在某些设备上会无法平铺。解决办法是把贴图尺寸调整为256x256或512x512这种2的幂次方尺寸。我一开始用Canvas画了一个300x300的砖墙纹理,结果在部分手机上显示异常,后来改成512x512就正常了。

5.2 性能问题:帧率低、卡顿

性能问题通常来自三个方面:面数太多、阴影太吃性能、渲染次数太多。面数方面,我一开始用了很多细分段数很高的几何体,比如SphereGeometry(1, 64, 64),一个球就几千个三角形。后来把不需要的细分降到最低,比如SphereGeometry(1, 16, 16),视觉上几乎没差别,但面数少了十几倍。

阴影方面,前面提到过,阴影贴图分辨率从4096降到2048,帧率立刻从20多升到50多。另外,不是所有物体都需要投射阴影,比如地面只需要接收阴影,不需要投射阴影,把castShadow设为false可以省不少性能。

渲染次数方面,如果场景是静态的,可以只在相机移动时渲染,而不是每帧都渲染。实现方式是监听OrbitControls的change事件,在事件回调里调用renderer.render,然后把animate循环里的渲染去掉。这样在用户不操作的时候,GPU几乎不工作,省电又流畅。

5.3 移动端适配问题

移动端的坑也不少。第一,触摸事件和鼠标事件不一样,OrbitControls默认支持触摸,但需要确保canvas的touch-action设为none,否则页面会跟着手指滚动。第二,移动端的性能比桌面端差很多,阴影和抗锯齿要适当降低。第三,移动端的屏幕比例和桌面端不同,相机的aspect要动态计算,不能写死。

我实测下来,在手机上把阴影关掉、抗锯齿关掉、像素比降到1.5,基本能跑到30帧以上,体验可以接受。如果追求更好的效果,可以考虑用LOD(Level of Detail)技术,根据距离远近切换不同精度的模型,但这个实现起来比较复杂,我这个项目没用。

5.4 常见问题速查表

问题现象可能原因解决方法
页面全黑相机位置不对或光照不足检查相机位置,增加环境光
贴图不显示路径错误或加载未完成检查控制台404,用onLoad回调
贴图显示为黑色光照不足或色彩空间错误增加光照,设置colorSpace
帧率低面数太多或阴影分辨率太高降低细分,阴影降到2048
移动端卡顿性能不足或像素比太高关阴影,像素比降到1.5
窗口缩放变形未监听resize事件添加resize监听,更新aspect
物体重叠或悬空坐标计算错误用辅助线调试,确认Y轴向上

6. 一些让项目更“母校”的细节处理

6.1 用Canvas动态生成校名和标语

校名和标语如果直接用字体文件渲染,需要加载字体,而且中文字体文件很大。用Canvas画文字然后转成纹理是最简单的方案。具体做法是创建一个canvas元素,用ctx.fillText画上文字,然后new THREE.CanvasTexture(canvas)生成纹理,贴到横梁或墙面上。字体可以用系统自带的黑体或宋体,不需要额外加载。文字的颜色、大小、位置都可以在Canvas里精确控制,比在3D里调整方便得多。

我实际做的时候,校名用了红色宋体,标语用了白色黑体,背景透明,贴到横梁上效果很好。需要注意的是,Canvas的尺寸要设得足够大,比如1024x256,这样文字才清晰。另外,纹理的minFilter要设为THREE.LinearFilter,避免远处文字模糊。

6.2 添加昼夜切换和天气效果

昼夜切换的实现思路是改变光照的颜色和强度,以及场景的背景色。白天用白色的平行光和浅蓝色的背景,傍晚用橙色的平行光和橙红色的背景,夜晚用深蓝色的环境光和深蓝色的背景。切换的时候用TWEEN或者简单的线性插值做过渡,避免突变。我用了最简单的lerp函数,在几秒钟内平滑过渡,效果还不错。

天气效果我只做了简单的雨和雪,用Points粒子系统实现。雨是细长的线段,雪是白色的小点,从天空往下落,落到地面以下就重置到顶部。这个效果对性能有一点影响,但不大,几千个粒子在现代设备上完全没问题。

6.3 用Node.js做简单的构建和部署

项目做完之后,我想把它部署到一个可以公开访问的地方。最简方案是用Node.js写一个静态文件服务,几行代码就行。用http模块创建服务器,读取文件返回,设置正确的MIME类型。然后把这个服务跑在一台常开的机器上,或者用一些免费的静态托管服务。我实际用的是Node.js的serve包,一行命令就能起服务,方便快捷。

构建方面,我把所有的JS代码合并成一个文件,用esbuild做压缩,体积从几百KB降到一百多KB。CSS也做了压缩。图片资源用imagemin压缩。整个项目最终打包出来不到500KB,加载速度很快。

7. 我踩过的那些坑和最后怎么绕过去的

第一个大坑是版本兼容性。我一开始用最新版的Three.js,结果OrbitControls的引入路径变了,从three/examples/js/controls/OrbitControls.js变成了three/addons/controls/OrbitControls.js,我照着旧教程写的一直报错,找了半天才发现是路径问题。所以看教程一定要看近期的,Three.js的API变化挺频繁的。

第二个坑是阴影的锯齿。我开了阴影之后发现边缘全是锯齿,很难看。后来查资料才知道要设置shadow.mapSize和shadow.radius,还要调整shadow.bias防止阴影痤疮。调了半天才找到一个看起来还行的参数组合。这个没有固定值,得根据场景大小和光照角度慢慢试。

第三个坑是移动端的触摸事件冲突。在手机上,手指滑动旋转场景的时候,页面也会跟着滚动,体验很差。后来在CSS里给canvas加了touch-action: none才解决。这个属性在桌面端没影响,但在移动端是必须的。

第四个坑是贴图的跨域问题。我从某个免费纹理网站下载的图片,直接引用URL的时候浏览器报跨域错误。后来把图片下载到本地,用相对路径引用就没事了。所以如果你也要用外部纹理,建议下载到本地,别直接引用外链。

8. 这个项目还能怎么扩展

如果你已经跟着做到了这里,恭喜你,一个基本的3D母校已经跑起来了。接下来可以玩的东西还很多。比如加一个第一人称漫游模式,用PointerLockControls实现鼠标锁定视角,WASD移动,就像在玩3D游戏一样逛校园。比如加一个热点标注功能,点击建筑弹出介绍文字,用Raycaster检测鼠标点击的物体。比如加一个时间轴,拖动滑块可以看到母校不同年代的样子,这个需要准备多套模型或者用变形动画实现。

还有一个方向是用真实数据生成地形。如果你能拿到母校的平面图或者高程数据,可以用代码生成更精确的地形和建筑布局。这个难度比较大,但做出来效果会很震撼。我目前只做了主楼和校门,下一步打算把图书馆和操场也加上,慢慢完善。

最后分享一个小技巧:做3D项目的时候,善用AxesHelper和GridHelper。前者显示坐标轴,后者显示网格地面,调试物体位置的时候非常有用。等场景调好了再把它们隐藏掉。我一开始没加这两个辅助工具,调位置全靠猜,效率很低。后来加上之后,位置调整快了很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询