ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Three.js基础地图:场景、相机、渲染器与常见坑排查

Three.js基础地图:场景、相机、渲染器与常见坑排查 我做了几年前端可视化带过不少新人发现大家学 Three.js 时的路径几乎一模一样先照着官方文档复制一个立方体成功渲染出来很兴奋然后开始加模型、加动画接着就掉进黑屏、穿模、颜色不对、性能卡顿的坑里出不来。今天这篇东西不打算复述一遍文档而是把我这些年实际踩过、填过的坑以及沉淀下来的 Three.js 基础地图按一个可复现的路径讲清楚。适合刚接触 WebGL、想做 3D 展示但被各种术语劝退的同学参考。1. Three.js的底层逻辑场景、相机、渲染器怎么配合1.1 为什么所有东西都必须放进Scene里很多新手拿到 Three.js第一句代码通常是const scene new THREE.Scene()然后照着教程往下写写完之后并不清楚这个 scene 到底是干嘛的。我用一句话给你讲透场景就是一张 3D 世界的总清单你所有要显示的东西几何体、灯光、辅助线、模型都要往里挂。这个挂在 Three.js 中通过scene.add(对象)完成。为什么不直接把物体丢给 renderer因为 renderer 只负责把世界里存在的东西画到屏幕上它自己不维护这份清单。场景对象会保存所有子节点并在每次渲染时把它们和相机一起交给渲染器。从架构上理解场景就像一个容器你可以在它下面建分组Group、建子场景子节点形成一个树状结构。实际项目里一个角色模型可能有几十个网格节点如果你不学会用 Group 组织后面做局部旋转、显隐切换会非常痛苦。补充一个很多人忽略的细节场景里还有一个scene.background属性。你不设置它默认就是透明背景网页的 CSS 背景会透出来所以renderer.setClearColor设了颜色却看不到效果的时候先检查是不是 scene.background 在作怪。我见过一堆人明明设了背景色却还是黑色就是被这个坑绊住的。1.2 相机不是用来看的它是一组投影参数相机在 Three.js 里最常用的就是透视相机THREE.PerspectiveCamera它的四个参数是 fov、aspect、near、far无数人栽在这四个参数上。fov 是视野角度单位是度不是弧度类似人眼的张角aspect 是画布宽高比near 和 far 是近裁剪面和远裁剪面只有在这个距离范围内的物体才会被渲染。用生活类比解释一下 near 和 far相机就像你站在窗口看外面的风景near 太大会把近处的窗台裁掉far 太小会让远处的楼消失。更关键的是精度问题——near 和 far 的比值越大深度缓冲的精度就越低物体之间会出现闪烁、错位。所以不要为了保险把 far 设成 100000合适范围比如 far 是场景最远物体的 2 到 3 倍反而更稳。另一个容易被忽略的参数是camera.position。Three.js 使用右手坐标系默认相机在原点如果你创建完物体忘了移动相机大概率会看到黑屏或者物体被相机穿在身体里。我的习惯是创建完相机立刻做三件事设 position如camera.position.set(5, 5, 10)、调用camera.lookAt(0, 0, 0)看向目标、最后记得把相机也 add 到场景里——虽然相机不 add 也能渲染但 add 进去后场景里的光照、雾效才会对它产生正确影响前期图省事不挂后期加雾就有各种怪问题。1.3 渲染器真正干活的那个画师渲染器THREE.WebGLRenderer是整个 Three.js 里最费配置的地方但核心就三件事创建、设尺寸、绑定 DOM。初学者最常见的错误是只调了renderer.setSize(window.innerWidth, window.innerHeight)却忘了处理窗口 resize 事件结果窗口一缩小画布就变形、拉伸。创建渲染器时有几个参数值得花时间理解。antialias: true是抗锯齿开启后边缘更平滑代价是性能稍微下降alpha: true控制画布是否保留透明度powerPreference: high-performance会告诉浏览器优先用独立显卡对做 3D 展示的页面来说是稳定提升帧率的一个重要选项。另外注意高 DPI 屏幕问题如果你直接 setSize(窗口宽, 窗口高)在 Retina 屏上渲染出来会发虚。正确做法是同时设置renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))让画布的分辨率跟上物理像素。设成 2 而不是原始值的原因是3 倍甚至 4 倍像素比会带来几何级数的片段着色压力画面提升很小性能损失极大除非你是做展示型大屏且机器配置很高否则设 2 是性价比平衡点。渲染器的核心方法只有一个renderer.render(scene, camera)。但注意这个方法只在调用的那一刻渲染一帧。你想要连续动画就得让它在每一帧被重复调用这就引出了后面要讲的动画循环。2. 新手必看Three.js基础概念里最容易踩的坑2.1 坐标系、单位与旋转规则Three.js 使用右手坐标系X 向右、Y 向上、Z 向屏幕外角度默认用弧度制。弧度这个坑很隐蔽你写mesh.rotation.y 90本意是旋转 90 度实际却转了差不多 5140 度转了好几圈物体几乎看不出变化或者朝向完全不对。我建议所有旋转操作统一写THREE.MathUtils.degToRad(90)或用Math.PI / 180 * 90换算避免心算弧度出错。关于单位Three.js 本身没有强制的一米标准但业界惯例是 1 单位约等于 1 米。这个约定在导入外部模型时尤其重要你从建模软件导出一个角色如果在建模软件里用厘米建模那模型导入后会有 100 倍的缩放问题。项目里如果遇到模型巨大或者微缩得看不见解决办法之一就是检查模型单位通过model.scale.set(0.01, 0.01, 0.01)做统一修正而不是一个个节点手动调。还有一个旋转相关的高级坑万向锁。如果你用 Euler 角rotation.x / y / z做连续旋转旋转到某些角度时会出现旋转轴重叠导致动画表现异常。解决思路是改用四元数THREE.Quaternion来存储旋转。实际中你不必完全掌握四元数运算只需要知道mesh.quaternion.copy(targetQuaternion)是比直接操作 rotation 更稳的路径特别是做人物头部的平滑朝向时这个认知能帮你省下大量调试时间。2.2 几何体、材质、网格的三层关系Three.js 里几乎没有所谓的一个物体你看到的每个立体模型底层拆开是三层几何体Geometry定义形状材质Material定义外观网格Mesh负责把两者组合成可渲染对象。新手不理解这层关系就容易写出每个帧循环里重新创建几何体这种灾难级代码。几何体的本质是顶点坐标 索引 法线。BoxGeometry看起来简单内部其实是 24 个顶点每个面 4 个6 个面各自独立而不是你直觉中的 8 个角点。为什么因为每个面需要独立的法线方向顶点如果在面之间共用法线就会被平均导致面与面的边界变得平滑立方体就变成圆角方块了。理解这一点后当你想实现只有一面可见的半透明纸片时就知道要设置材质的side: THREE.DoubleSide否则从另一面看就是透明的。材质的选择和场景的光照模式强相关。MeshBasicMaterial不响应光照纯色输出适合做调试、做辅助线MeshLambertMaterial是简单的漫反射适合低性能需求的场景MeshPhongMaterial支持镜面高光做塑料、陶瓷质感够用MeshStandardMaterial是 PBR 物理材质配合环境贴图能做出丰富的金属、粗糙度质感但性能开销也最大。我的建议是移动端项目优先 Lambert展示型项目用 Standard不要一上来就上 Standard 然后抱怨运行卡。2.3 光照不是照亮那么简单光照在 Three.js 里是另一个祸根。很多人搞了一个好看的地球模型加了一个DirectionalLight结果发现场景比不开灯还黑或者颜色看起来完全不对。核心原因在于标准材质的颜色值是基于物理的它需要光的强度、方向、衰减等信息来计算最终像素。你没有光源时标准材质直接黑掉你加了光源但光源位置没摆对它就只照亮一面。常用的灯光类型按照使用频率排AmbientLight环境光提供全局基础亮度不会产生阴影适合做底光DirectionalLight平行光模拟太阳需要指定 position 和 targetPointLight点光源有位置、有衰减适合做灯泡效果SpotLight聚光灯有角度和衰减范围适合做舞台效果。逐个解释参数背后的意义。DirectionalLight 的 position 只是方向参考距离远近不影响强度但方向必须明确——你可以直观理解为太阳光无论太阳在哪光线是平行的所以只有方向重要。PointLight 的distance和decay共同决定衰减默认 decay2 模拟物理衰减如果你把 distance 设得很大、decay 设成 0灯光就会变成一个无限远不衰减的球场景整体看起来会过度发白。此外AmbientLight 还有一个常见的反直觉点环境光强度太高模型就完全没有立体感所有的暗面都被抬亮了。我做室内场景时 AmbientLight 强度很少超过 0.5主要靠 DirectionalLight 和补光来塑形。2.4 动画循环为什么直接写 while 循环会卡死Three.js 动画的标准姿势是requestAnimationFrame(animate)递归调用。为什么不能用while(true)因为浏览器的渲染节奏是由浏览器自己控制的requestAnimationFrame 会告诉浏览器每一帧刷新前请调用我而 while 会占满主线程连 UI 都动不了。但光有 requestAnimationFrame 还不够你还需要对帧间时间差做处理。不同显示器的刷新率不一样60Hz、120Hz、144Hz如果你每次旋转角度直接写mesh.rotation.y 0.01在 144Hz 的屏幕上这个旋转速度比 60Hz 快 2.4 倍。正确做法是用THREE.Clock获取 delta 时间然后基于 delta 做速度换算mesh.rotation.y speed * delta。speed 的单位是弧度/秒这样无论刷新率多高速度表现都一致。一个更隐蔽的细节是动画里对物体做旋转、移动时你修改的是position和rotation但 Three.js 内部真正用于计算的其实是矩阵matrix。每次你改 pos 或 rot引擎都会重新计算物体的 local matrix然后向上传播更新世界矩阵。这是自动的但你如果逐帧频繁改动大量模型节点这个矩阵重算的开销会卡出明显掉帧。优化的思路是尽量把静态部分合并成一个网格mergeGeometry或者用层级 Group 控制一次性更新而不是逐节点修改。3. 从零搭建第一个可运行场景逐行拆解可复制的代码3.1 环境准备不用脚手架也能跑起来先明确一个理念Three.js 不像前端框架那样必须要脚手架它的最小运行环境就是一个 HTML 文件加一个 JS 引用。你可以用 npm Vite 搭工程也可以用 CDN 引入两个方案我都在生产项目里用过。这里我给一个不依赖任何构建工具、复制到本地就能跑起来的方案适合先跑通逻辑再迁移到工程化环境。注意下面示例用了 importmap 方式引入 ES Module 版 Three.js。如果你把文件直接放在本地用 file:// 协议打开会有跨域问题建议起一个本地静态服务比如 VS Code 的 Live Server 插件或者 Python 的python -m http.server这是 Web 3D 调试的标配环境。在 HTML 的 head 里放 importmapscript typeimportmap { imports: { three: https://unpkg.com/three0.160.0/build/three.module.js, three/addons/: https://unpkg.com/three0.160.0/examples/jsm/ } } /script我特意加了第二个映射three/addons/因为后面要用的 OrbitControls、GLTFLoader 等扩展工具都在这个目录下。没有这个映射你import { OrbitControls } from three/addons/controls/OrbitControls.js就会报错找不到模块。3.2 核心代码逐行拆解造出你的第一个 3D 世界HTML 部分只需要一个占满屏幕的容器div idcontainer/div script typemodule import * as THREE from three; import { OrbitControls } from three/addons/controls/OrbitControls.js; // 1. 创建场景 const scene new THREE.Scene(); scene.background new THREE.Color(0x111122); // 2. 创建相机 const camera new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 100 ); camera.position.set(4, 3, 6); camera.lookAt(0, 0, 0); // 3. 创建渲染器 const renderer new THREE.WebGLRenderer({ antialias: true, powerPreference: high-performance }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); document.getElementById(container).appendChild(renderer.domElement); // 4. 加物体一个金属感立方体 const geometry new THREE.BoxGeometry(1, 1, 1); const material new THREE.MeshStandardMaterial({ color: 0x44aaff, metalness: 0.6, roughness: 0.2 }); const cube new THREE.Mesh(geometry, material); scene.add(cube); // 5. 加地面否则视觉上没有参照系 const groundGeometry new THREE.PlaneGeometry(10, 10); const groundMaterial new THREE.MeshStandardMaterial({ color: 0x888888, side: THREE.DoubleSide }); const ground new THREE.Mesh(groundGeometry, groundMaterial); ground.rotation.x Math.PI / 2; ground.position.y -1; scene.add(ground); // 6. 加灯光 const ambient new THREE.AmbientLight(0xffffff, 0.3); scene.add(ambient); const dirLight new THREE.DirectionalLight(0xffffff, 1.5); dirLight.position.set(3, 5, 2); scene.add(dirLight); // 7. 加辅助工具 const axesHelper new THREE.AxesHelper(5); scene.add(axesHelper); // 8. 轨道控制器 const controls new OrbitControls(camera, renderer.domElement); controls.enableDamping true; controls.dampingFactor 0.08; // 9. 动画循环 function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); } animate(); // 10. 窗口自适应 window.addEventListener(resize, () { camera.aspect window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); /script逐块解释一下为什么这么写。第 4 步用了 MeshStandardMaterial 而不是 Basic目的就是让你直观看到光照对 PBR 材质的影响——如果你们把第 6 步注释掉立方体立刻漆黑暗淡这个体验比看文档 100 遍都深刻。第 5 步加一个平面作为地面一方面给视线提供参照另一方面是为了验证旋转和坐标系的理解——plane 默认在 XY 平面旋转 x 轴 90 度后才变成水平地面如果不旋转或者转反方向地面会竖着或者倒着。第 8 步是轨道控制器它把鼠标拖拽变成摄像机的旋转、平移、缩放。它不改变场景渲染逻辑而是监听鼠标事件每帧更新 camera 的 position、rotation 等参数。设置enableDamping后有惯性效果拖拽结束后镜头会平滑移动体验更加自然。但注意damping 生效的前提是你在动画循环里调用controls.update()漏了这行拖拽会异常生硬甚至松开后继续漂移。第 10 步的 resize 监听极其重要。窗口尺寸变化时如果只改相机 aspect 不调用updateProjectionMatrix()画面会维持旧投影参数只会被拉伸变形如果只改渲染器 size 不更新相机画面比例又对不上。这两个步骤必须成对出现。3.3 加载真实模型从几何体跨越到生产级素材几何体堆出来的方块是入门玩具真实项目最终一定会用到外部模型。目前最常见的格式是 glTF/GLB它是当前 Web 3D 事实上的标准格式因为它把几何体、材质贴图、动画、骨骼全部打包在一个文件里而且对 Web 渲染做了针对性优化。使用 GLTFLoader 的代码长这样import { GLTFLoader } from three/addons/loaders/GLTFLoader.js; const loader new GLTFLoader(); loader.load(model.glb, (gltf) { const model gltf.scene; scene.add(model); }, (xhr) { console.log(加载进度, (xhr.loaded / xhr.total) * 100 %); }, (error) { console.error(加载失败, error); });三个回调分别对应成功、进度、失败。前两个回调用途好理解第三个回调是最容易被忽略的完全没有失败处理的代码会导致模型不显示时你只能对着黑屏发懵。实际项目里我至少会在失败回调里区分两种情况404 找不到文件还是解析器报错这两种排查路径完全不同。加载成功后还有两个高频问题。一是模型中心点不在原点导致它飞在场景中央很别扭解决办法是遍历模型计算包围盒然后重新设置模型的 position 让它居中二是模型比例和场景不匹配用model.scale.set统一缩放。这两个步骤我习惯写成一个通用工具函数因为几乎每个模型加载场景都要用。3.4 让场景动起来从旋转到可控动画把第一步里的立方体动起来你只需要在动画循环里加一句cube.rotation.y 0.5 * delta;这里 delta 来自THREE.Clock。改造一下 animateconst clock new THREE.Clock(); function animate() { const delta clock.getDelta(); cube.rotation.y 0.5 * delta; controls.update(); renderer.render(scene, camera); requestAnimationFrame(animate); }clock.getDelta()返回上一帧到这一帧的时间差单位是秒。0.5 的含义是每秒旋转 0.5 弧度大约 9.5 秒转完一圈。为什么用速度乘以 delta 而不是固定步长前面已经解释过基于 delta 的写法对刷新率无关同时又天然适配慢动作和加速效果把 speed 变成变量按需调整即可。做动画时还有一个容易被忽略的问题动画帧率过高或过低时delta 可能出现极端值。比如在移动端页面切后台再切回来第一次 delta 可能是一秒多甚至更大物体就会瞬间跳一大段。应对方法是对 delta 做钳制clamp比如delta Math.min(delta, 0.1)保证动画步进不会因为后台恢复而跳变。这种细节官方文档不会写但实际项目里必会遇到。4. Three.js常见问题与排查技巧实录4.1 渲染结果黑屏先分清是相机没看见还是灯没照亮黑屏是 Three.js 新手遇上的第一大问题。我的排查顺序固定是先把 MeshStandardMaterial 临时改成 MeshBasicMaterial。如果改完能看见物体说明几何体和相机没问题问题出在光照配置如果改了还是黑的那大概率是相机没对着物体或者物体尺寸/位置和相机距离不匹配。相机相关的黑屏最常见原因有三种near 和 far 范围不对物体在裁剪范围之外camera 的位置在物体内部视线刚好穿过物体内部导致看不出形状camera.lookAt没有设置或者目标方向不对。我建议创建完相机后立刻打印相机位置和目标点坐标或者直接用controls.target.set(0, 0, 0)把 orbiter 的焦点固定到场景中心这一步能解决 80% 的看不见问题。光照相关的黑屏典型情况是场景里只有 MeshStandardMaterial 材质却没有添加任何光源。这是很多从 Basic 材质入门的人踩坑的重灾区。另一个隐蔽情况是光源加上了但是强度写低了比如AmbientLight(0xffffff, 0.05)几乎看不见效果。排查时先把所有灯光强度都临时调大到 2 或 3确认能看到了再往回落。4.2 模型不显示或显示为黑色检查贴图加载和跨域如果你加载的是外部模型且模型显示为纯黑色第一个怀疑对象就是贴图没加载成功。GLTF 文件里的贴图通常是相对路径引用如果路径不对材质就会在无贴图状态下渲染成黑色模型。浏览器控制台通常会有 404 错误提示这是排查入口。有一类更隐蔽的黑色贴图加载了但没有触发。Three.js 的纹理加载是异步的如果你在纹理未加载完成时就渲染部分版本会出现材质整体变黑。解决方案有两个一是把模型加载好后统一加入场景不要在 loader.load 回调之外引用未加载完成的模型二是渲染前确认贴图的texture.image已经就绪。项目里我更推荐用 LoadingManager 统一管理加载进度全部加载完成后再启动渲染循环代码结构清晰也天然避免了异步竞态问题。跨域问题则是本地调试的重灾区。用file://协议打开 HTML 时浏览器为了安全会阻止本地文件读取贴图资源表现是模型加载成功但贴图黑乎乎的。解决办法前面提过起本地服务访问而不是双击打开文件。4.3 画面卡顿掉帧绘制调用、几何复杂度与像素比Three.js 项目的卡通常不是 GPU 渲染慢而是 JS 主线程被大量计算占用了。最常见的问题是绘制调用draw calls过多。每一个独立 Mesh 在渲染时都会产生一次 draw call你场景里有 1000 个立方体就是 1000 次 draw call这会让渲染性能急剧下降。应对策略是合并几何体。能把多个静态网格合并成一个就用 BufferGeometryUtils.mergeGeometries 合并如果形态各异的物体必须独立尽量减少材质种类因为同材质物体可以合并渲染批次。还有一个小技巧将不必要的模型visible false而不是 remove便于后续快速恢复同时 remove 掉的物体如果涉及其它系统还持有引用可能有内存泄漏风险。几何复杂度同样会拖垮帧率。一个工业模型动辄几百万面直接放网页上基本卡到没法玩。实际生产里我会在建模阶段就要求模型减面到合理范围移动端建议 10 万面以内桌面端 30 万面以内同时用模型查看器在导入前确认三角形数量。如果模型不是自己建的也可以用 Three.js 结合简化算法做减面但效果通常不如建模软件里干净。还有一个容易被忽略的参数像素比。前面提到setPixelRatio(Math.min(window.devicePixelRatio, 2))如果你设置的是设备原始值 3 甚至 4GPU 的着色压力会直接翻好几倍。我调过一个 4K 大屏项目画面只有几帧把像素比从 3 降到 1.5 后帧率立刻恢复到流畅视觉效果几乎没差别。4.4 颜色发暗、失真伽马空间与颜色管理的坑很多人用 Three.js 加载贴图时发现颜色和建模软件里不一致偏暗或者偏灰这是因为颜色空间没有正确设置。传统 Web 渲染默认工作在 sRGB 空间而 Three.js 内部计算常用的物理渲染管线使用的是线性空间。简单说你告诉渲染器的颜色是显示用的 sRGB但着色器计算时按线性空间处理如果不做转换结果就会偏暗。正确做法有两步。第一渲染器做颜色空间转换renderer.outputColorSpace THREE.SRGBColorSpace;第二贴图纹理声明颜色空间texture.colorSpace THREE.SRGBColorSpace;但注意颜色空间转换不适合所有纹理比如用于金属度、粗糙度、法线贴图的纹理应该保持线性空间不应该设置 SRGBColorSpace。区分方法凡是你用眼睛看颜色的贴图颜色贴图、漫反射贴图需要 SRGB 转换凡是用数值参与计算的贴图法线、粗糙、AO保持线性即可。光照和材质的物理正确性也会影响颜色。MeshStandardMaterial 是 PBR 材质光源的强度有物理含义如果把 DirectionalLight 强度设成 3 或 5画面会整体过曝白色区域失去细节。很多新人看到颜色发白就减少灯光的数量实际上应该调节的是强度而不是去掉光源。正确理解这几个概念后颜色问题基本能解决大半。4.5 常见问题速查表一眼定位的排查手册我把平时支持新人时最常用的排查点整理成一个速查表供你复制到项目文档里遇到问题按表定位就行。现象可能原因快速排查与修复整体黑屏或看不见物体相机位置不对 / near、far 不匹配临时改用 Basic 材质判断是否为光照问题检查 camera.lookAt物体存在但纯黑Standard 材质无光源 / 贴图未加载加 AmbientLight 并调高强度检查贴图路径 404物体翻转或镜像坐标旋转方向反了检查 rotation 的正负号回顾右手坐标系规则动画速度在不同设备上不一致直接累加固定步长改用 Clock 计算 delta乘以固定速度常数窗口缩放后画面拉伸resize 事件中忘记更新投影矩阵同步执行 updateProjectionMatrix 与 setSize画面模糊 / 边缘锯齿严重像素比太低或没有抗锯齿开启 antialias合理设置 setPixelRatio模型巨大或渺小建模软件单位与场景不一致统一缩放 model.scale通常为 0.01 或 100颜色偏暗 / 发灰没有设置 SRGB 颜色空间设置 outputColorSpace 和贴图 colorSpace大场景卡顿 / 帧率低draw calls 过多 / 像素比过高合并几何体、减少独立材质、降低 pixelRatio旋转到某些角度动画异常欧拉角万向锁改用 quaternion 控制旋转表格列的这些现象基本覆盖了新手前三个月的所有常见问题。实际操作中遇到不在表里的问题我的通法是在 render 之前打console.log(scene, camera, mesh)检查三者的位置坐标是否合理检查材质和几何体是否已经正确创建。一定不要把渲染循环关掉只做静态调试——动画状态下的 bug 往往是静态分析看不出来的。最后分享一点个人经验刚开始学时我最大的错觉是Three.js 是万能的 3D 引擎所有东西都应该用它来画。用了一段时间才发现它更适合做交互式场景、产品展示、数据可视化而不是做极致性能的游戏引擎。选型阶段想清楚这一点就能避免后面很多返工。还有一个实在的建议项目里从第一天就规范好坐标系约定、单位约定、资源加载管理方式哪怕一个人写也要定好这个习惯会在项目变大后帮你维持住控制力。Three.js 入门不难难的是把基础概念里的隐含约定搞清楚把这些摸透了后面学 shader、学骨骼动画都会顺很多。
返回列表