多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Three.js实战:WebGL知识、贴图问题与云间列车案例

Three.js实战:WebGL知识、贴图问题与云间列车案例 做WebGL这几年隔三差五就有人在群里问同一个问题Three.js和WebGL到底是不是一回事我打个比方吧——WebGL是浏览器给你的一块画布加一堆底层绘制指令Three.js则是把这堆指令收拾得井井有条的现成工具箱。你可以拿原生WebGL从零画一个三角形但真要做一个带纹理、带光影、还能交互的三维场景手写着色器代码会把人逼疯而Three.js把这条路的坑填平了大半。这篇文章不聊虚的就围绕大家最近搜得比较多的几个点展开怎么快速把Three.js项目跑起来、贴图为什么死活不显示、以及一个挺有意思的实战案例“云间列车”。不管你是刚接触图形学的学生还是做数据可视化、数字孪生、互动营销页的工程师这三块内容基本覆盖了从入门到上手的完整路径。1. 先理清关系WebGL与Three.js谁是谁很多人以为Three.js是个独立的三维引擎其实它本质上是WebGL的上层封装。搞清楚这层关系后面遇到任何诡异问题都不容易慌。1.1 WebGL到底是什么WebGL是浏览器提供的一组JavaScript API基于OpenGL ES 2.0/3.0标准允许你通过GPU加速来渲染图形。它的工作方式很“底层”你要自己管理顶点数据、编写着色器程序、上传纹理、设置绘制状态然后告诉GPU“现在开始画”。任何一个环节出错画面要么黑屏要么出现不可描述的撕裂效果。我早期用原生WebGL画过一个带纹理的立方体光是把纹理坐标和顶点坐标对上就折腾了一个晚上。后来想加个光照又得自己写光照着色器那一刻我彻底理解了为什么社区会催生Three.js这种东西。1.2 Three.js做了哪些事Three.js把原生WebGL的复杂度封装成了几个大白话概念场景Scene、相机Camera、渲染器Renderer、几何体Geometry、材质Material、光源Light。你不需要写一行着色器代码也能渲染出一个带阴影、带反射、带动态模糊的三维场景。更重要的是Three.js帮你解决了大量工程问题模型加载glTF、OBJ、FBX、纹理加载与格式处理、阴影映射、粒子系统、动画系统、相机控制、性能优化如实例化绘制、LOD等等。这些都是原生WebGL里需要从头造轮子的活儿。但注意Three.js不是万能的。它仍然跑在WebGL之上所以你还是要理解渲染管线的核心逻辑顶点怎么变成三角形、纹理怎么贴上去、光照怎么影响最终颜色。否则出了性能瓶颈或者渲染异常你会连查问题的方向都没有。2. 快速创建项目从下载到第一个场景跑起来“Three.js怎么下载”“Three.js快速创建项目”这两个搜索词一直很热说明很多人卡在了第一公里的环境搭建上。2.1 下载与引入方式的选择现在Three.js官方推荐的方式已经非常现代化有三种主流选择通过npm安装npm install three适合用Vite、Webpack等构建工具的项目。通过CDN引入直接使用ES Module方式比如从unpkg或jsdelivr加载适合快速写个Demo页。从官网下载官方包发布在GitHub的mrdoob/three.js仓库进入build目录下载three.module.js本质上和CDN是同一个文件。我的建议是如果是正经项目直接用npmVite如果是临时写个测试页用CDN最快。官方下载这个选项现在用得很少除非你处于完全离线的环境。2.2 一个最小可运行的项目结构用Vite创建一个项目安装依赖npm create vitelatest my-three-demo -- --template vanilla cd my-three-demo npm install npm install three然后打开src/main.js写入下面这段最基础的代码import * as THREE from three; // 1. 创建场景 const scene new THREE.Scene(); // 2. 创建相机透视相机视野角、宽高比、近裁面、远裁面 const camera new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 ); // 3. 创建渲染器 const renderer new THREE.WebGLRenderer(); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); // 4. 添加一个立方体 const geometry new THREE.BoxGeometry(1, 1, 1); const material new THREE.MeshStandardMaterial({ color: 0x00aaff }); const cube new THREE.Mesh(geometry, material); scene.add(cube); // 5. 调整相机位置 camera.position.z 5; // 6. 渲染循环 function animate() { requestAnimationFrame(animate); cube.rotation.x 0.01; cube.rotation.y 0.01; renderer.render(scene, camera); } animate();这段代码里有两个容易忽略的细节。第一MeshStandardMaterial如果不加任何光源物体是纯黑的因为标准材质需要参与光照计算。很多新手在这里以为渲染器坏了其实是没加灯光。第二renderer.render(scene, camera)必须在每一帧中调用而不是只调用一次。2.3 为什么这四个对象缺一不可场景、相机、渲染器、几何体这四样东西的关系可以用一个拍电影的例子来解释。场景就是舞台上面摆什么演员、道具、灯光都由你说了算相机决定了观众从哪个位置、以什么角度看到舞台渲染器是摄像机负责把舞台上的实况录制并输出到屏幕上几何体和材质则是舞台上的演员几何体是演员的骨骼和体型材质是演员的衣服和妆容。少了任何一个环节最终都出不了画面。3. 核心细节拆解渲染管线里的那些关键环节项目跑起来只是第一步真正要用Three.js做出能看的场景你必须吃透几个核心概念。这里我把最容易出问题、也最影响效果的部分拎出来讲。3.1 相机参数为什么重要很多人写new THREE.PerspectiveCamera(75, width/height, 0.1, 1000)的时候根本没想过这四个数字代表什么。视野角75度适合大多数场景但如果你想模拟手机游戏的沉浸感可以用60度以下如果想做鱼眼效果可以调到100度以上。宽高比必须跟随窗口尺寸变化否则画面会被拉伸变形。近裁面和远裁面是另一个隐蔽的坑。如果你做一个大型城市模型相机离地面只有几米但远处有几十公里外的地标建筑默认的远裁面1000可能看不到远处的东西你需要把它调大到足够覆盖场景的最远距离。但也不要无脑调大这会影响深度缓冲区的精度可能导致远近物体交替时出现闪烁。3.2 材质选择直接决定画面质感Three.js里常用的几种材质区别非常大材质特点适用场景MeshBasicMaterial不受光照影响纯色或纯贴图调试用、UI元素、卡通风格MeshLambertMaterial支持漫反射光照性能较好性能受限的移动端场景MeshPhongMaterial支持高光反射、镜面效果塑料、金属感的物体MeshStandardMaterial基于物理渲染支持金属度与粗糙度写实场景、产品展示MeshPhysicalMaterial在标准基础上支持透射、清漆玻璃、液体、高端材质我个人做项目时90%的场景用MeshStandardMaterial因为它最接近真实世界的物理表现。但要注意物理材质对光照数量和贴图质量都很敏感光线不够时物体显得死黑贴图分辨率太低时细节全是糊的。3.3 光源怎么配才不翻车光源是新手最容易搞砸的部分。很多人往场景里加了四五个DirectionalLight结果物体还是黑的或者过度曝光。记住一个原则先加一盏AmbientLight做基础照明再加一盏主方向光做主要明暗关系需要补光时加HemisphereLight模拟天空与地面的环境色点光源和聚光灯只在特定氛围下使用。灯光数量和性能之间也要权衡。每个光源都会增加着色器的计算负担尤其是开启阴影之后。移动端场景建议控制在2到3个动态光源以内其余可以用烘焙好的光照贴图代替。4. 贴图不显示十个里有八个是这些原因“three.js贴图开始不显示”这个热搜词我太熟悉了因为我被这个问题折磨过无数次也在各种社群里看过无数人栽跟头。这里把最常见的原因和排查方法一次说清楚。4.1 纹理加载是异步的const texture new THREE.TextureLoader().load(textures/map.jpg);load方法是异步的纹理文件没加载完之前texture对象里是空的。如果你在初始化时就拿着这个纹理去创建材质贴图自然不显示。解决办法有几个// 方式一用回调 const texture new THREE.TextureLoader().load( textures/map.jpg, () { console.log(贴图加载完成); } ); // 方式二在animate渲染循环中先判断texture.image是否存在 // 方式三用async/await封装 async function loadTexture(url) { return new Promise((resolve) { new THREE.TextureLoader().load(url, resolve); }); }我推荐第三个方案代码结构最清晰。另外要注意有些情况下纹理加载成功了但渲染不出来是因为纹理尺寸不是2的幂次方比如1024x1024就是而800x600就不是这会导致mipmap生成失败。出现这种情况时把图片尺寸调整为2的幂次方或者设置texture.generateMipmaps false。4.2 丢给材质的方式对不对贴图不显示还有一个高频原因是材质属性放错了。材质上的color是颜色值map才是贴图通道。有人做对了map但忘了设置texture.wrapS和texture.wrapT导致贴图被压缩或拉伸得面目全非。// 正确的贴图方式 const material new THREE.MeshStandardMaterial({ map: texture, // 贴图通道 roughness: 0.8, // 粗糙度 metalness: 0.1 // 金属度 });还有一个隐蔽的问题Three.js r152版本之后默认的纹理颜色空间从LinearEncoding换成了SRGBColorSpace。如果你是从旧项目升级可能会出现贴图颜色发灰、发暗的情况。解决办法是显式设置texture.colorSpace THREE.SRGBColorSpace;4.3 跨域和路径问题用TextureLoader加载本地图片时默认的file://协议会被浏览器拦截因为存在跨域问题。你需要在本地起一个服务用Vite或者npx serve都可以再访问页面。部署到服务器后如果图片放在CDN或其他域名下也要确保服务器返回了正确的CORS头。路径问题则是相对路径和绝对路径搞混。建议所有资源通过一个统一的路径工具函数管理不要手写相对路径不然项目目录结构一调整贴图就全面失效。5. 玩点有意思的做一个“云间列车”场景热搜里的“webgl云间列车”挺戳人的。我一开始以为是某个具体的游戏或者案例后来想想这不就是个典型的三维场景练习吗。列车在云层间穿行既涉及模型、材质、光照又涉及动画、粒子、相机跟随做一遍几乎能把Three.js的核心功能全过一遍。5.1 场景设计天、云、轨道、列车我按四层结构来组织这个场景。第一层是天空。最简单的做法是给Scene.background设置一个渐变纹理或者用THREE.Fog配合背景色营造深远感。我更推荐用雾加背景色的组合可以同时解决远景裁剪和氛围营造两个问题。第二层是云。云的方案有三种用大量平面贴合透明贴图性能最好、用粒子系统适合小规模云朵、用体素噪声生成网格效果最好但贵。我做了个折中方案用几十个THREE.Sprite精灵每个精灵的贴图是一张径向渐变模糊的圆形噪音图随机分布在轨道两侧的高空。这样的云在远处看非常真实性能开销也很小。第三层是轨道。轨道不一定要真的铺铁轨你可以做成一条发光的曲线比如光带列车沿着曲线行驶。曲线的定义用THREE.CatmullRomCurve3这是Three.js自带的三次样条曲线传入几个控制点就能得到一条平滑路径。第四层是列车。最简单的做法是用几个BoxGeometry和CylinderGeometry拼出一个卡通列车头车身用长条方体车轮用圆柱。如果想让细节好一些可以直接从网上下载glTF格式的火车模型用GLTFLoader加载import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader.js; const loader new GLTFLoader(); loader.load(models/train.gltf, (gltf) { scene.add(gltf.scene); });5.2 让列车动起来路径跟随要让列车沿着曲线走核心逻辑是通过curve.getPointAt(t)获取路径上某一时刻的位置再用curve.getTangentAt(t)获取该位置的切线方向然后让列车的position和quaternion跟着更新。let t 0; const speed 0.001; // 每秒前进的比例 function updateTrain(time) { t (t speed * time) % 1; const position curve.getPointAt(t); const tangent curve.getTangentAt(t); train.position.copy(position); // 让列车朝向切线方向 const target position.clone().add(tangent); train.lookAt(target); }这里有一个坑lookAt默认会让物体的Z轴正方向指向目标点。如果列车的模型本身是正Z朝前那没问题但很多模型导入后车头方向在Y轴或者X轴。遇到这种情况你需要在lookAt之后手动修正姿态或者把模型套在一个容器对象里容器跟随路径内部模型做一次旋转校正。5.3 相机跟拍与氛围渲染相机可以跟着列车走也可以固定在轨道中间的某个高点。两种方式的感觉完全不一样跟拍更有代入感适合第一人称固定机位更有电影感适合展示场景。我建议做“后置跟拍”加“可切换视角”相机始终在列车后方偏上位置不仅跟随位置还要平滑跟随朝向。用矩阵变换来做会比较绕简单一点的做法是每帧把相机位置设为列车位置的偏移然后同样用lookAt看向列车前方远处的点。最后再加上雾和体积光效让云层画面更透气。注意Three.js的体积光不太好做需要自定义着色器或使用UnrealBloomPass这类后期效果。新手可以先跳过用雾效和合适的背景色就已经能支撑起氛围了。6. 常见问题速查报错、黑屏、性能卡的排查思路最后整理一份我实际踩过的问题速查表按出现频率排序遇到问题直接对照处理。现象大概率原因排查与解决办法页面空白 / 屏幕全黑只调用了renderer.render一次但后续没有渲染循环在requestAnimationFrame里持续渲染物体是黑色用了MeshStandardMaterial但没加光源至少加一盏AmbientLight加一盏方向光贴图不显示纹理异步加载未完成或colorSpace没设置用回调/await处理显式设置SRGBColorSpace贴图颜色发灰旧项目升级后颜色空间不一致给纹理设置texture.colorSpace THREE.SRGBColorSpace模型加载404路径用的是相对路径且项目目录调整过用统一的资源路径解析函数画面模糊 / 物体边缘锯齿渲染器没有开抗锯齿或分辨率不一致new THREE.WebGLRenderer({ antialias: true })物体闪烁 / 穿模近裁面与远裁面差值过大合理设置相机近裁面和远裁面页面卡顿模型面数太多或光源数量过多用THREE.BufferGeometry合并几何体、减少光源透明贴图出现黑边纹理边缘像素半透明与背景混合设置material.alphaTest或调整纹理边缘透明度这里再补充两个经验。第一个是关于性能的如果你发现渲染卡顿先打开浏览器DevTools的Performance面板看是GPU瓶颈还是CPU瓶颈。CPU瓶颈通常是几何体数量太多、阴影计算过重GPU瓶颈通常是像素填充率吃满需要降低渲染分辨率或减少后期特效。第二个是关于调试的我习惯在开发时给渲染器加上renderer.debug.checkShaderErrors着色器报错会直接输出到控制台省去很多猜谜时间。如果你做的是大场景应用还可以考虑用THREE.InstancedMesh来大量绘制重复物体比如森林里的树木、场景里的路灯它能把几百次绘制调用合并成一次性能提升非常可观。最后的几句实在话折腾Three.js这几年我最深刻的体会是它的上手门槛比大部分人想象的低但天花板比大部分人想象的高。你能在半小时内用十几行代码渲染一个旋转立方体但要做到电影级的画面和流畅的交互背后是对图形学基础、渲染管线优化和美术审美的综合考验。给刚入坑的朋友一个建议不要急着学各种炫酷的后处理特效先把场景、相机、材质、光照这四个基础模块玩熟然后动手做一个小项目就像“云间列车”这样的把模型加载、贴图、动画、相机控制都过一遍。踩几个坑之后你会发现Three.js的世界其实挺通透的。如果这篇文章写到的某个问题正好戳中你现在的处境照着排查清单一步步来就好。多做几个小项目这些东西自然就内化成肌肉记忆了。
返回列表