1. 从“今日案例&热点”这个栏目说起
做前端可视化这些年,我养成了一个习惯:每天固定花二十分钟刷一遍当天的 Three.js 案例和社区热点。这个习惯坚持了快两年,最大的感受是——Three.js 这个生态的迭代速度远比大多数人想象的要快,但真正值得动手复现的东西其实并不多。很多案例看着炫酷,点进去一看源码,要么是堆了一堆后处理特效掩盖了几何体本身的粗糙,要么是拿现成的模型库直接套了个环境贴图就发出来了。所以“今日案例&热点”这类栏目,核心价值不在于“今天又有什么新东西”,而在于帮你做一轮筛选:哪些案例背后的技术点值得你花时间吃透,哪些只是视觉上的花架子。
这篇文章我想聊的是,当你拿到一个 Three.js 案例时,怎么快速判断它的技术含金量,怎么拆解它的实现路径,以及怎么把里面真正有用的部分吸收到自己的项目里。适合已经能写基础场景、但对“怎么从案例中学习”还比较迷茫的开发者。如果你刚开始接触 Three.js,也能看懂,因为我会把每个技术点的来龙去脉讲清楚,不假设你熟悉所有 API。
2. 拆解一个 Three.js 案例的正确姿势
2.1 先看渲染管线,再看视觉效果
很多人看案例的第一反应是“这个效果好看,我要怎么实现”。这个顺序其实是反的。正确的做法是先判断它用了哪条渲染管线:是默认的 WebGLRenderer 直接渲染,还是走了 EffectComposer 后处理链路,还是用了 WebGPU 的新管线。这个判断决定了你后续拆解的复杂度。
怎么快速判断?打开案例页面,按 F12 看 Network 面板里加载了哪些 JS 文件。如果看到 EffectComposer、RenderPass、UnrealBloomPass 这类文件名,基本可以确定走了后处理。如果看到大量 .glsl 或 .frag 文件,说明用了自定义着色器。如果只加载了一个 three.module.js 加一个主逻辑文件,那大概率是原生渲染,技术重点在几何体构建或材质参数上。
这个判断为什么重要?因为后处理链路和原生渲染的调试思路完全不同。原生渲染出问题,你调的是材质、光照、几何体;后处理出问题,你调的是 pass 的顺序、混合模式、渲染目标的格式。搞混了方向,排查效率会低很多。
2.2 几何体是买的还是造的,决定了学习价值
我判断一个案例值不值得深入研究的第二个标准是:它的核心几何体是外部模型加载的,还是用代码程序化生成的。这两者的学习价值差距很大。
外部模型加载的案例,技术点通常集中在材质调优、环境光照、动画混合上。这类案例适合学习“怎么让模型好看”,但不太适合学习“怎么构建三维形状”。程序化生成的几何体,技术点在于数学计算、顶点操作、参数化建模,这类案例适合学习“怎么用代码描述形状”。
举个例子,一个用程序化方式生成的参数化建筑立面,里面可能涉及贝塞尔曲线采样、法线重计算、UV 展开逻辑,这些才是真正能迁移到你项目里的硬技能。而一个加载了现成角色模型、只做了骨骼动画的案例,你学到的更多是 AnimationMixer 的用法,这个 API 本身并不复杂。
2.3 性能数据比视觉效果更能说明问题
一个案例的视觉效果再惊艳,如果帧率只有 20fps,那它的实现方案就值得商榷。我在看案例时会习惯性打开 Performance 面板,看几个关键指标:draw call 数量、三角形数量、每帧的 GPU 时间。
draw call 超过 500 的案例,通常说明没有做合批处理,这种方案在实际项目中很难直接用。三角形数量超过 200 万的,要看它有没有做 LOD 或视锥剔除。GPU 时间超过 16ms 的,基本就告别 60fps 了。
这些数据比截图更能告诉你一个案例的真实水平。很多案例在展示页面上跑得流畅,是因为场景简单、视角固定,一旦你把它放到自己的项目里,性能问题立刻暴露。
3. 近期高频技术点的深度拆解
3.1 程序化材质与自定义着色器
最近一段时间,我观察到社区里程序化材质的案例明显增多。所谓程序化材质,就是不用贴图,完全靠着色器代码计算出表面颜色和纹理。这类案例的技术核心在于噪声函数的运用。
常见的噪声函数有 Perlin 噪声、Simplex 噪声、Worley 噪声。Perlin 噪声适合做云朵、地形这类连续变化的纹理;Simplex 噪声计算量更小,适合实时场景;Worley 噪声适合做细胞、裂纹这类有明确边界的纹理。
在 Three.js 里用自定义着色器,有两条路:一是用 ShaderMaterial 完全自己写着色器,二是用 onBeforeCompile 在标准材质上打补丁。前者灵活但工作量大,后者能复用标准材质的光照计算,适合做局部修改。
我个人的经验是,如果你的需求是替换整个表面的着色逻辑,用 ShaderMaterial;如果只是想在标准材质基础上加一层细节,用 onBeforeCompile。后者有个坑要注意:onBeforeCompile 修改的是材质的 shader 字符串,Three.js 版本升级时字符串内容可能变化,导致你的补丁失效。所以用这种方式时,最好锁定 Three.js 版本,或者在代码里做版本判断。
3.2 实例化渲染与性能优化
实例化渲染是近期案例里另一个高频出现的技术点。简单说,就是把同一个几何体的多次绘制合并成一次 draw call。Three.js 提供了 InstancedMesh 来实现这个功能。
InstancedMesh 的核心是实例矩阵。每个实例有一个 4x4 的变换矩阵,决定了它的位置、旋转、缩放。你可以在创建时传入一个矩阵数组,也可以在运行时通过 setMatrixAt 动态修改。
这里有个容易踩的坑:InstancedMesh 的实例数量在创建时就固定了,不能动态增减。如果你的场景需要动态添加或删除实例,要么预留足够的实例数量、把不用的实例缩放到 0,要么用多个 InstancedMesh 分组管理。
另一个坑是视锥剔除。InstancedMesh 默认把整个实例集合当作一个整体来做剔除,如果实例分布范围很广,剔除效果会很差。解决办法是手动计算包围盒,或者把实例按空间位置分成多个 InstancedMesh。
3.3 后处理链路的取舍
后处理是让案例“看起来高级”的常用手段,但也是最容易滥用的。我见过太多案例,明明场景本身很简陋,硬是靠 Bloom 和景深撑场面,结果就是画面糊成一团,细节全丢了。
后处理的核心取舍在于:每个 pass 都会增加一次全屏渲染的开销。在移动端,这个开销尤其明显。所以我的原则是,后处理只用在刀刃上。Bloom 适合做发光效果,但阈值要调高,只让真正亮的区域发光;景深适合做微距效果,但焦距和光圈要配合场景尺度;色彩校正适合统一画面色调,但不要过度饱和。
还有一个容易被忽略的点:后处理的顺序很重要。一般来说,先做几何相关的 pass(如景深),再做颜色相关的 pass(如 Bloom、色彩校正)。顺序反了,效果会不自然。
4. 从案例到项目的落地实操
4.1 建立自己的案例拆解模板
看了这么多案例,我慢慢总结出一套拆解模板,每次遇到值得研究的案例,就按这个模板过一遍。模板包含五个部分:渲染管线、几何体来源、材质方案、动画系统、性能数据。
渲染管线部分记录它用了哪些 pass、顺序如何。几何体来源记录是加载的还是生成的,如果是生成的,用了什么数学方法。材质方案记录用了哪些材质类型、有没有自定义着色器。动画系统记录用了哪些动画技术、是骨骼动画还是程序化动画。性能数据记录 draw call、三角形数、帧率。
这个模板的好处是,当你积累了几十个案例的拆解记录后,你会发现很多案例的技术方案是重复的。这时候你就可以把重复的部分抽象成自己的工具函数或组件,下次遇到类似需求直接复用。
4.2 最小复现:把案例拆到只剩核心
拆解完一个案例后,我通常会做一件事:用最少的代码复现它的核心效果。这个过程叫最小复现。
最小复现的关键是剥离所有非核心的东西。UI 控件去掉、加载动画去掉、无关的装饰性元素去掉,只保留产生核心效果的那部分代码。这个过程会强迫你理解哪些代码是真正必要的,哪些只是锦上添花。
举个例子,一个案例的核心效果是程序化生成的地形。那最小复现就只需要:一个平面几何体、一个噪声函数、一个顶点位移逻辑、一个基础材质。光照可以用最简单的环境光加平行光,相机可以用固定视角。这样复现出来的代码可能只有原案例的十分之一,但核心逻辑一目了然。
4.3 参数化改造:让案例适应你的需求
最小复现之后,下一步是参数化改造。把案例里写死的数值抽成参数,这样你就能快速调整效果,适应不同场景。
参数化改造有个技巧:先找出所有影响视觉效果的数值,按重要性排序,然后从最重要的开始抽。比如地形生成案例里,噪声频率、振幅、octave 数量是最重要的三个参数,先抽这三个。抽完之后,你可以通过调整这三个参数,快速生成不同风格的地形。
改造过程中要注意,有些参数是有关联的。比如噪声频率和振幅,频率高了振幅通常要降低,否则地形会太尖锐。这种关联关系最好在代码里体现出来,比如提供一个预设系统,每个预设是一组协调好的参数值。
5. 常见问题与排查技巧实录
5.1 画面全黑或全白怎么排查
这是 Three.js 新手最常遇到的问题。排查思路按以下顺序来:
首先检查相机位置和朝向。相机如果在几何体内部,或者朝向反了,就会看到全黑。可以在场景里加一个 AxesHelper,确认坐标轴方向。
其次检查光照。如果用的是 MeshStandardMaterial 或 MeshPhysicalMaterial,没有光源就是全黑。加一个 AmbientLight 试试,如果亮了说明是光照问题。
然后检查材质。如果材质是 ShaderMaterial,着色器编译失败也会导致全黑。打开控制台看有没有着色器编译错误。
最后检查渲染循环。如果 renderer.render 没有被调用,或者调用时场景为空,也会全黑。
全白通常是过曝。检查光照强度是否过高,或者后处理里的 Bloom 阈值是否过低。
5.2 模型加载后不显示或显示异常
模型加载的问题通常出在路径、格式、缩放这三个方面。
路径问题:检查加载器的路径是否正确,特别是相对路径和绝对路径的区别。跨域问题也会导致加载失败,看控制台有没有 CORS 报错。
格式问题:Three.js 支持 glTF、OBJ、FBX 等格式,但不同格式需要不同的加载器。glTF 推荐用 GLTFLoader,OBJ 用 OBJLoader,FBX 用 FBXLoader。用错加载器会报错或加载出空对象。
缩放问题:有些模型导出时单位是厘米,导入到 Three.js 里会巨大无比,相机在模型内部所以看不到。解决办法是加载后计算包围盒,根据包围盒尺寸自动调整缩放和相机位置。
5.3 性能突然下降的排查清单
性能问题往往不是单一原因造成的,我整理了一个排查清单,按优先级排序:
| 排查项 | 检查方法 | 常见问题 |
|---|---|---|
| draw call | renderer.info.render.calls | 超过 500 需要合批 |
| 三角形数 | renderer.info.render.triangles | 超过 200 万需要 LOD |
| 纹理内存 | renderer.info.memory.textures | 大纹理未压缩 |
| 着色器复杂度 | 看 fragment shader 行数 | 循环嵌套过多 |
| 后处理 pass 数 | 看 EffectComposer 的 pass 数量 | pass 过多 |
| 阴影贴图 | 看 shadow map 分辨率 | 分辨率过高 |
| 动画更新 | 看每帧更新了多少对象 | 不必要的矩阵更新 |
这个清单我用了很久,大部分性能问题都能在前三项里找到原因。
5.4 着色器编译失败的常见原因
自定义着色器编译失败,控制台会给出错误行号,但那个行号是编译后的行号,和你的源码行号对不上。这时候可以打开 WebGL 的着色器调试工具,或者用 console.log 把着色器源码打印出来,手动比对。
常见的编译失败原因有:变量未声明、类型不匹配、函数名拼写错误、缺少分号。还有一个隐蔽的原因是在着色器里用了保留字,比如sample在某些 WebGL 版本里是保留字。
6. 工具链与调试环境搭建
6.1 本地开发环境的最小配置
我现在的 Three.js 开发环境很简单:Vite 加原生 Three.js,不引入任何框架封装。Vite 的好处是启动快、热更新快,而且对 ES module 的支持很自然。
目录结构上,我习惯把着色器代码单独放在 shaders 目录下,用 .glsl 文件保存,通过 Vite 的 ?raw 后缀导入。这样着色器代码有语法高亮,写起来舒服很多。
调试工具方面,我装了两个 Chrome 扩展:一个是 WebGL Inspector,用来抓帧分析;一个是 Spector.js,用来查看每一帧的 draw call 和状态。这两个工具配合使用,大部分渲染问题都能定位。
6.2 版本管理:锁定 Three.js 版本的重要性
Three.js 的版本迭代很快,而且不保证向后兼容。我踩过好几次坑,都是因为升级版本后某些 API 变了,导致项目跑不起来。
所以我现在所有项目都锁定 Three.js 版本,在 package.json 里写死版本号,不用 ^ 或 ~。升级版本时,先在一个分支上跑一遍所有功能,确认没问题再合并。
另外,Three.js 的 examples 目录下的代码变动更频繁,如果你用了 examples 里的模块,升级时更要小心。我的做法是把用到的 examples 模块复制到自己的项目里,而不是直接从 node_modules 导入。这样即使 Three.js 升级了,你的代码也不会受影响。
6.3 性能监控的常态化
性能监控不应该等到出问题才做,而应该常态化。我在开发阶段就会在页面上放一个小的性能面板,显示帧率、draw call、三角形数。这样一旦某个改动导致性能下降,立刻就能发现。
Three.js 自带的 renderer.info 提供了大部分需要的数据。帧率可以用 requestAnimationFrame 的时间差计算。把这些数据渲染到一个固定的 DOM 元素上,开发时随时能看到。
上线前,我会用 Performance 面板录一段完整的交互流程,看有没有卡顿帧。卡顿帧通常出现在场景切换、大量对象创建或销毁的时候,找到这些点做针对性优化。
7. 从案例中提炼可复用的技术资产
7.1 建立个人着色器片段库
着色器代码有很多是通用的,比如噪声函数、光照模型、颜色空间转换。这些代码每次重写很浪费时间,我建了一个着色器片段库,按功能分类保存。
比如噪声函数分类下,有 Perlin 噪声、Simplex 噪声、Worley 噪声的实现,每个都标注了适用场景和性能特点。光照模型分类下,有 Lambert、Phong、PBR 的简化实现。颜色空间分类下,有 sRGB 和线性空间的转换函数。
用的时候直接复制粘贴,改改参数就能用。这个库我维护了两年多,现在遇到新需求,大部分情况下都能找到可复用的片段。
7.2 抽象通用组件
除了着色器片段,一些常用的 Three.js 对象也可以抽象成组件。比如轨道控制器、环境光遮蔽、阴影管理,这些几乎每个项目都会用到。
抽象的原则是:把配置项暴露出来,把内部实现隐藏起来。比如一个环境光遮蔽组件,对外只需要暴露强度、半径、采样数这几个参数,内部怎么创建 pass、怎么配置渲染目标,使用者不需要关心。
这样抽象之后,新项目搭建时,把这些组件一挂,基础功能就有了,可以把精力集中在业务逻辑上。
7.3 案例拆解笔记的整理方法
最后说一下案例拆解笔记的整理。我用的工具是 Notion,每个案例一个页面,按拆解模板的五个部分记录。页面里会贴关键代码片段、性能数据截图、以及我自己的点评。
点评部分最重要,记录的是“这个案例哪里做得好、哪里可以改进、如果我来做会怎么做”。这些点评积累多了,就形成了自己的技术判断力。
我还会给每个案例打标签,比如“程序化生成”“后处理”“性能优化”“着色器技巧”。这样当我要研究某个特定方向时,可以快速找到相关案例。
8. 一些个人体会
做 Three.js 这些年,我最大的体会是:不要被案例的视觉效果迷惑,要透过效果看实现。一个案例的价值不在于它看起来多炫,而在于它用了什么技术、解决了什么问题、这些技术能不能迁移到你的项目里。
另外,不要贪多。每天看十个案例,不如把一个案例吃透。吃透的标准是:你能用最少的代码复现它的核心效果,能说清楚每个技术选择的理由,能把它改造成适应你自己需求的样子。
最后,保持动手。看再多案例,不动手写一遍,那些技术点就永远是别人的。只有自己踩过坑、调过参数、优化过性能,那些知识才真正变成你的。