开头直接进入主题,不绕弯子。WebGL和Three.js这些词在可视化、数字孪生、3D互动页面的圈子里已经不算新鲜了,但很多人是“下载了、装好了、打开文档看到一堆例子,真正要动手时卡住了”。尤其是“云间列车”这类看起来很炫的场景,拆开看无非是模型贴图加动画再加一点后期处理,真不难。这篇就按我自己从零搭项目的实际过程来写,从WebGL这里头到底是怎么一回事说起,到Three.js官网该怎么看、项目怎么搭、模型贴图为什么一开始不显示,以及后面性能优化该从哪下手,都捋一遍。适合刚入门想搞清楚原理的人,也适合已经写过两三个demo但遇到瓶颈的人。
1. 先搞清楚WebGL和Three.js到底在解决什么问题
1.1 WebGL不是用来画图的,是用来和GPU对话的
很多人一上来就查WebGL的API,结果看到一堆gl.createBuffer、gl.bindBuffer、gl.shaderSource,直接劝退。我最初也干过这事,硬啃了几天官方文档,感觉像是在学一门和平时写网页完全不同的语言。
WebGL的本质是一个浏览器内置的JavaScript接口,它让你能够调用本机的GPU(图形处理器)来执行渲染任务。它不像Canvas 2D那样“你画一条线它就给画一条”,而是让你把顶点数据、颜色数据、纹理数据统统装进缓冲区,然后交给一个叫Shader(着色器)的程序去处理。
可以用一个生活化类比:Canvas 2D像是你直接拿笔在纸上画,怎么画全靠你一笔一笔来。WebGL更像你是在给一台印刷机下指令——你要告诉它纸张规格、油墨颜色、印刷速度、放哪个模板,然后它一次性给你印一整批。所以WebGL的优势是性能,批量处理、效率极高;代价是繁琐,你要自己管的东西太多。
实际操作里,就算是只画一个三角形,你也得自己写着色器、创建缓冲区、绑定数据、编译程序、链接程序,然后才能发起一次绘制调用。这个复杂度对大部分前端开发者来说根本没有必要直接面对,于是Three.js这类库就应运而生。
1.2 Three.js扮演的其实是“封装层”的角色
Three.js的定位很简单:它把WebGL底层那一堆繁琐的API封装成一套场景图结构。你在Three.js里写代码,永远不会直接碰到gl.bindBuffer这种函数,你面对的是Scene、Camera、Mesh、Material、Light这些有明确意义的概念。
举个具体例子,用原生WebGL画一个带纹理的立方体,你至少要写一百多行代码,还要自己处理矩阵运算,一个算错了显示出来就是黑屏或者图形错位。用Three.js,核心代码就十几行:
const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); const renderer = new THREE.WebGLRenderer(); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshBasicMaterial({ color: 0x00ffff }); const cube = new THREE.Mesh(geometry, material); scene.add(cube); camera.position.set(2, 2, 2); camera.lookAt(0, 0, 0); function animate() { requestAnimationFrame(animate); cube.rotation.x += 0.01; cube.rotation.y += 0.01; renderer.render(scene, camera); } animate();运行起来就是一个能转的方块。代码直观到什么程度?你基本能猜到每一行在干什么。这就是Three.js最核心的价值——它把你从GPU细节里解放出来,让你把精力完全放回“场景怎么设计”“模型怎么摆”“动画怎么调”这些创造性的工作上。
1.3 “云间列车”这类场景到底由哪些部件构成
很多人看到“webgl云间列车”的效果很惊讶,怀疑是不是用了什么高级特效。实际拆开看,任何一个三维场景的组成都跑不出这四个东西:
- 场景(Scene):一个容器,所有物体都往里放。
- 相机(Camera):决定观察者从哪个角度、以什么视野看世界。
- 模型(Mesh/Object):场景里的实体,由几何体、材质、贴图共同定义。
- 环境要素(灯光、天空盒、粒子、后期特效):负责把场景氛围烘托出来。
云间列车要成立,无非是轨道模型加列车模型重现在场景里,周围铺上云雾粒子,天空用一个立体感很足的背景,再调好光照方向。原理层面的东西并没有超出基础范畴。
真正让新手难受的,是后面那些“为什么我照代码写了还是不对”的问题。下面这几章全是我实际踩过的坑,直接按顺序排。
2. 快速把项目搭起来:从链接库到第一个页面
2.1 引入Three.js的三种方式,怎么选
“three.js下载”这个词搜的人特别多,说明大家对怎么把库弄进自己项目里还不太有把握。目前主流的引入方式三种,区别很明显。
第一种是CDN方式,适合做测试、写小demo,空HTML里直接写。但生产环境不推荐,因为依赖外网资源,加载慢,而且版本管理很麻烦。
第二种是npm方式,适合正经做工程化项目。执行npm install three之后,在JS里import * as THREE from 'three'就行。这是目前团队协作、长期维护最靠谱的方式。需要注意:Three.js从r150版本左右开始,已经全面转向ESModule。用importmap这种方式直接在浏览器里跑也是官方推荐的方案。
第三种是直接下载整个three.module.js文件放到自己项目里。这种适合那些不能上npm、网络又不太方便的内网开发场景。Three.js的下载入口其实就在官网的Download按钮里,进去能找到各个版本的压缩包,解开后build目录下就是各种模块化文件。还有一点要提醒:最好锁定一个固定版本,不要三天两头升级,很多API在不同版本之间有变化,老项目一升级就崩的情况我遇到过太多次。
提示:如果你的项目还要用到扩展功能(比如模型加载器、轨道控制器),这些不在核心包three里,需要从three/examples/jsm/里单独引入。这类代码包结构很多人第一次看到会懵,其实就是在node_modules/three/examples/jsm/下面按模块分类放好的,按需取用即可。
2.2 Vite搭项目的完整实操过程
不建议直接用官方那种一整个HTML文件堆代码的方式做正式项目。页面简单还好,一旦场景复杂,文件会膨胀得根本没法维护。
我自己现在最常用的组合是Vite加Three.js,创建方式很简单:
npm create vite@latest my-three-project -- --template vanilla cd my-three-project npm install three装完依赖之后,在src/main.js里把基础场景搭好。这里有几个值得注意的点:
- renderer的antialias参数记得开true,否则模型边缘锯齿感很强,看起来特别廉价。
- setPixelRatio不要设太高,设成Math.min(window.devicePixelRatio, 2)就可以。设高了GPU压力成倍上涨,尤其移动端发热降频非常明显。
import * as THREE from 'three'; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 2000); camera.position.set(0, 5, 12); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshStandardMaterial({ color: 0x409eff }); const mesh = new THREE.Mesh(geometry, material); scene.add(mesh);从这步开始,任何Demo都可以在这个骨架基础之上往外长。
2.3 关于渲染循环:requestAnimationFrame为什么不可替代
写Three.js迟早会遇到一个问题:动画只能动一下,或者画面根本不更新。原因基本都是出在渲染循环上。Three.js的画面不是自动变的,你每调用一次renderer.render(scene, camera),才渲染一帧。所以要让它动起来,就要让他持续渲染。
标准的做法是用requestAnimationFrame这个浏览器原生的API,它会在每次浏览器准备刷新画面时调用你传进去的那个函数,通常和屏幕刷新率同步,也就是60Hz左右。代码结构是递归式的:
function animate() { requestAnimationFrame(animate); // 更新逻辑:让模型动、相机移动、粒子飘 mesh.rotation.x += 0.01; renderer.render(scene, camera); } animate();有人会拿setInterval替代,我建议不要。setInterval是固定时间间隔执行的,跟浏览器帧节奏对不上,轻则动画卡顿,重则白屏闪烁。requestAnimationFrame本身就是为动画设计的,还自带性能调节——标签页切到后台时浏览器会自动暂停它的执行,不会白白耗电。
顺带提醒一个细节:如果你用了OrbitControls(轨道控制器),必须在render调用之前先调用controls.update(),不然转动视角时画面会发飘,感觉是“延迟跟随”的。这是因为控制器内部维护了相机的目标状态,需要每一帧手动同步。说实话这个坑当年我排查了大半个晚上。
3. 解决“贴图开始不显示”的终极排查方案
3.1 为什么贴图加载失败的时候,模型会变成黑色或紫色
这个太经典了。几乎每个用Three.js的人都会遇到一次“贴图开始不显示”的问题。热搜词里有这个词,说明不是个例。
先说最常见的表现:模型加载了,几何体也能看到,但表面是纯黑色的,或者五颜六色的马赛克。很多人第一反应是代码写错了,其实大部分情况是贴图纹理根本没加载进来。区别在于:Three.js加载贴图是异步的,页面渲染出来的时候纹理图片可能还在网络上下载中;没有贴图的材质又需要光照才能显示颜色,一旦光照不足或纹理缺失,模型看起来就是黑乎乎的一片。
第二种常见表现是材质变成紫红色或荧光色。这通常是GLB模型引用了一个失败的外部贴图路径,Three.js拿到不到贴图就给了个默认的占位色。
3.2 贴图不显示的逐个排查流程
我把排查路径整理成一整套,直接从树状图的最根节点开始看:
第一步,先在代码里断开模型加载,单独给一个纯色材质铺上去,比如MeshStandardMaterial加一个颜色。如果连纯色都显示不对,说明问题根本不在贴图上,是光照或相机设置出了问题。如果纯色正常但贴图不对,那问题集中在纹理加载这一层。
第二步,检查贴图路径是否正确。在Vite这种构建工具里,直接写一个相对路径字符串'textures/wood.jpg'其实是有潜在风险的——代码里写的路径和最终打包部署的路径未必一样。更稳妥的做法是用import导入图片资源,让构建工具帮你处理路径。
import woodTextureUrl from './assets/wood.jpg'; const texture = new THREE.TextureLoader().load(woodTextureUrl);第三步,用TextureLoader的onLoad回调来确认加载状态:
const loader = new THREE.TextureLoader(); loader.load( 'path/to/texture.jpg', (texture) => { console.log('贴图加载成功', texture); material.map = texture; material.needsUpdate = true; }, undefined, (err) => { console.error('贴图加载失败', err); } );如果走到onError回调里,那就是网络路径问题,检查服务器上这个文件是否真实存在、跨域头是否允许。
第四步,确认UV坐标。如果你用的是外部导入的模型,UV一般在建模软件里已经展好了。但如果是代码里面直接用BoxGeometry或者自定义几何体,UV默认基本都有。真正会出问题的是:你觉得贴图方向不对、拉伸了,那多半是几何体UV分布和纹理图片的比例不一致。比如一个正方形的贴图贴到细长立方体侧面,拉伸是必然的。这种只能在建模软件里修好UV再导出。
注意:贴图文件本身不要直接用手机拍的JPG。手机照片动不动三四千像素宽,直接作为纹理贴上去,显存占用会非常大,移动端很可能加载到一半页面直接崩了。正规做法是压缩到1024甚至512像素以内,格式优先用WebP或经过压缩处理的JPG。
3.3 为什么贴图看起来像蒙了一层雾
贴图加载成功了,但模型看起来灰灰的、像是在大雾里,这个也特别常见。出现这种问题时,绝大多数情况不是贴图坏了,是材质和光照的配合出了问题。
MeshStandardMaterial这个材质遵循物理光照模型,它需要光照才能正确显示颜色混合。场景里如果不加Light,或者加了Light但位置没对准,贴图显示出来就会偏暗、偏灰,像被脏东西蒙住。
解决思路是给场景里补两盏基本光:一盏环境光(AmbientLight)保证整体不下沉,一盏方向光(DirectionalLight)制造明暗层次。数值可以现场调,环境光在0.3到0.6之间比较合适,方向光强度1到1.5。
还有一点很多人不知道:MeshStandardMaterial默认受环境遮蔽和光照影响,如果你只是想快速预览一个贴图模型是不是正常加载,可以临时改材质类型为MeshBasicMaterial。BasicMaterial不受光照影响,贴图什么颜色就显示什么颜色,排查问题非常好用。但注意这是临时方案,正式渲染时BasicMaterial没有光照细节,场景会显得特别“平”,就是那种一眼假的感觉。
4. 从Demo到“云间列车”级别的实际场景
4.1 模型加载:GLB文件是当前最省心的选择
“云间列车”这种场景里,列车模型、轨道、站台、轨道两侧的建筑,通常都是建模师在Blender或3ds Max里做好,导出成GLB(GLTF二进制格式)再放进Web端用的。
Three.js官方的GLTF加载器在examples/jsm/loaders/GLTFLoader.js这个路径下,用起来很直白:
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; const loader = new GLTFLoader(); loader.load('/models/train.glb', (gltf) => { const model = gltf.scene; scene.add(model); }, undefined, (error) => { console.error('模型加载失败:', error); });这里有一个非常关键的实操细节:模型加载完成后,务必检查一下模型的尺寸和坐标。建模软件里的单位往往和Three.js的世界尺度对不上,有时候模型是100米高的巨物,一加载进来相机直接“嵌”在模型内部,屏幕上看就是一片纯色或黑色。我一般的做法是加载后立刻打印一下包围盒信息:
const box = new THREE.Box3().setFromObject(model); const size = box.getSize(new THREE.Vector3()); console.log('模型尺寸', size);通过尺寸判断是否需要缩放。比如云间列车场景里,列车模型一般缩放到五到十个单位左右就差不多了。还要把模型位置整体对齐到原点或者轨道中心,不然后面相机轨道动画会绕着一个歪斜的轴心旋转。
4.2 云层和雾效:氛围感的大头其实是后期处理
很多人以为“云间列车”里的云是模型,其实是粒子系统加透明贴图做出来的。用Points做一片粒子云,每一粒粒子是一个带透明纹理的平面,配合缓慢的位移,效果比真做模型省大量性能。
粒子云的代码大致是:
const cloudTexture = new THREE.TextureLoader().load('/textures/cloud.png'); const cloudMaterial = new THREE.PointsMaterial({ map: cloudTexture, color: 0xffffff, transparent: true, opacity: 0.6, depthWrite: false, size: 8, }); const cloudGeometry = new THREE.BufferGeometry(); // 在指定范围内生成随机粒子位置 const positions = []; for (let i = 0; i < 300; i++) { positions.push( (Math.random() - 0.5) * 60, Math.random() * 8 + 2, (Math.random() - 0.5) * 60 ); } cloudGeometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); const clouds = new THREE.Points(cloudGeometry, cloudMaterial); scene.add(clouds);再加一个Fog就能把远处硬边藏掉。Three.js的Fog本质上是让远处物体逐渐混入一个指定颜色,用法很简单:
scene.fog = new THREE.Fog(0xcbddf5, 20, 80);雾的颜色要和场景天空背景色一致,否则远处物体会混入一个很奇怪的紫色或灰色调。云间列车里雾色用偏蓝白的色调最合理,叠加方向光之后,整体会很像雨后晴天的云海。
4.3 相机动画:让镜头跟着列车走
场景里最有“作品感”的部分往往不是模型,而是镜头。列车在轨道上移动,镜头跟随列车轻微晃动,同时视角微微看向前方,这种电影感的镜头运动,在Three.js里就是用相机位置和lookAt目标随时间变化做出来的。
最简单的做法是维护两个变量:
const trackProgress = { t: 0 }; // 0到1,表示沿轨道走了多少 // 在动画循环里更新相机位置 function updateCamera() { trackProgress.t += 0.001; // 速度控制 const pos = railwayPath.getPointAt(trackProgress.t); // railwayPath是一个CatmullRomCurve3 const lookTarget = railwayPath.getPointAt(Math.min(trackProgress.t + 0.02, 1)); camera.position.copy(pos); camera.lookAt(lookTarget); }这里的railwayPath用CatmullRomCurve3定义一串路径点即可,它会自动生成平滑曲线。云间列车场景里把路径拉几个高低起伏,镜头就会有沿山坡爬坡下坡的感觉,代入感强得多。
一个小技巧:相机别和列车绑定得完全同步,稍微延迟一点,或者在lookAt的目标点上加一点随机扰动,画面会自然得多。其实是模拟了手持摄影或者乘客身体的轻微晃动,帧与帧之间的位置变化不再机械,观感立刻上一个档次。
5. 性能优化与工程化经验:页面不能跑两步就卡死
5.1 顶点数和Draw Call是两道生死线
Three.js场景能不能跑得流畅,核心看两个指标:顶点总数和Draw Call数量。顶点数是GPU的工作量,Draw Call是CPU到GPU的通信次数。前者太高,GPU会拖不动;后者太高,CPU会闲不下来,页面同样卡。
手游建模标准和Web端标准其实很像:单个角色的顶点数尽量控制在2万到5万以内,轨道列车这种静态模型可以稍微放宽,但一整个场景的顶点数最好不要超过几十万级别。写业务代码的人容易忽略这点,以为贴个图就行,实际模型面数是美术那边定的,沟通时一定要把所有模型的预算说清楚,不然做出来就是灾难。
Draw Call这条更有意思。一个模型同一个材质,理论上渲染一次;但如果出现一千个不同材质的小物件,就意味着一千次Draw Call。解决办法有几个:
- 合并几何体:用BufferGeometryUtils.mergeGeometries把相同材质的小物件合并成一个整体。
- 实例化渲染:大量重复物体(路灯、树木、轨道枕木)用InstancedMesh,一次Draw Call渲染几十上百个实例。
- 纹理图集:同一场景里尽量共用少的贴图,不同物件用一张纹理里的不同区域(UV偏移),减少材质切换次数。
5.2 帧率和监视器:用数据说话
每次写完一个项目,我会在页面里挂一个简易的帧率监视器。Three.js官方提供了stats.js这个小工具,引入后页面角落就能看到实时帧率变化,非常直观。
实际测试时看看两类数据:静止观察时的帧率,以及镜头快速转动时的帧率。后者更能反映真实体验,因为人眼对镜头运动时的掉帧最敏感。如果快速转动时帧率掉到30帧以下,优化方向就先查Draw Call和着色器复杂度,别急着堆设备。
移动端性能比桌面端低一个数量级,这是常识。做云间列车这种场景,所有纹理贴图都要给移动端单独导一份低分辨率版本,粒子数量也得控制在移动端可接受的范围内。真到上线阶段,项目至少要在中端安卓机上跑一遍,不能只盯着自己那台高性能电脑看效果。
5.3 工程化整理:别让一个场景文件长到两千行
Demo阶段无所谓,文件堆一起也能跑。项目一正经,缺失工程化组织会让你后期想改点什么都不敢动手。我这里提供一个个人常用的目录组织方式,供参考:
- scenes文件夹放场景初始化逻辑
- models文件夹放模型加载器封装
- controls文件夹放相机控制、交互逻辑
- shaders文件夹放自定义着色器
- utils文件夹放通用工具函数,比如加载进度提示、FPS监视
这样把场景、模型、交互拆开,每次只改一个文件,其余不受影响。Three.js自身的扩展机制也很成熟,往后期走还可以用自定义ShaderMaterial写真正的特效着色器,比如列车的粒子尾迹、水面反射、以及云层边缘的光晕效果。这些专题都能单独展开成一篇一篇的内容,但基础就是先把前面这些底子打好——渲染循环跑起来了,贴图显示正常了,模型加载顺利了,性能数据稳住了,后面怎么扩展都只是动手的问题。
6. 个人经验里最想留给新手的几句话
实际写过几个Three.js项目之后,回头看最花时间的地方永远不是“怎么写”,而是“为什么不显示”“为什么不运动”“为什么卡”。这类问题九成都是小细节:相机没加、光照没配、路径不对、忘了更新controls。我的习惯是当页面出现异常时,不先往复杂了猜,而是按顺序检查一个最简单的固定流程:纯色材质能不能显示、几何体在不在原点、相机朝向是否正确、光照有没有加上。一层层排除,最多二十分钟就能定位。
另外,对WebGL和Three.js的学习节奏,我的建议是:不必强迫自己把所有原生WebGL文档啃完再动手,也不必迷信一个月学完所有案例。找个真实的小目标,比如就做一个列车穿云的小场景,从搭环境开始一步步走通,中间遇到所有问题顺手就深入查明白了。你离“能做出来”之间,往往只隔一个具体需求的距离。