材质和光照系统是three.js里最让新手头疼、也最能拉开画面差距的两个板块。代码逻辑写清楚了,几何体也加载进来了,但画面要么漆黑一片,要么像塑料模型一样假,十有八九问题就出在材质和光源的配合上。这篇文章我从零开始,把three.js的材质类型、光源体系、参数逻辑和实战布光流程完整过一遍,讲清楚每个选择背后的原因和实际踩坑经验,适合刚接触three.js、正在为画面效果发愁的开发者参考。
1. 先弄明白:为什么材质和光照必须放在一起学
大家在网上看到的那些炫酷three.js作品,背后的场景其实不复杂,区别主要在于对材质和光照的理解深度。单独看材质或单独看光照,都会陷入"参数我都设了,效果就是不对"的尴尬。
1.1 光照是"因",材质是"反应"
材质决定了一个表面的颜色、粗糙度、金属感、透明度,光照则决定这些属性如何被我们看到。用生活化的类比来说,材质是物体的皮肤,光源是打在皮肤上的光。同一个人,在日光灯下和在夕阳下看起来完全不同,不是因为皮肤变了,而是因为光照变了。反过来,同样的光照条件下,一块金属和一块布料的反光效果天差地别,这就是材质在起作用。
three.js遵循的逻辑正是如此:渲染器把光源信息传给材质,材质根据自身的参数去计算每个像素应该显示什么颜色。所以如果你用了不受光照影响的材质,光源的能量再强也没有意义;反过来,如果你用了依赖光照的材质却不打灯,场景就会一片漆黑。
1.2 一个被光照参数折磨的真实场景
我见过不少新手写的第一段three.js代码长这样:创建了一个MeshStandardMaterial(标准材质),往场景里加了一个AmbientLight(环境光),然后奇怪为什么物体看起来扁平的,没有立体感。还有人加了PointLight(点光源),但光照强度给了1000,画面过曝到只剩白色。
这类问题的根子在于:光照强度、衰减、材质粗糙度、金属度这些参数是协同工作的,单独调某一个往往适得其反。理解了光源类型和材质属性的对应关系之后,你会发现自己不再依赖"试参数试到满意为止",而是能直接根据想要的效果反向推导出该怎么做。这也是这篇博文想帮你达到的状态。
2. three.js材质家族:从"占位符"到"物理真实"
three.js的材质体系经历了几代迭代,从最初的简单着色模型,到现在的基于物理渲染(PBR),理解这个演进过程有助于你理解每个材质的位置。
2.1 材质分类三维度:是否受光、是否物理真实、性能开销
维度一,材质是否对光源产生响应。MeshBasicMaterial完全不理会场景里的任何灯光,它显示的就是贴图或颜色本身,像一张贴纸。MeshLambertMaterial、MeshPhongMaterial以及后面一系列Standard、Physical材质都会对光源产生反应。这个区别在调试时很关键:如果你的物体是黑的,先检查是不是用错了Basic材质。
维度二,是否基于物理真实。MeshStandardMaterial用金属度(metalness)和粗糙度(roughness)来描述表面,MeshPhysicalMaterial在它基础上扩展了清漆层、透光率等物理属性。而MeshLambertMaterial和MeshPhongMaterial是早期经验模型,它们计算快但不够真实。
维度三,性能开销。Basic最省、Lambert次之、Phong中等、Standard和Physical最贵。这个排序在移动端开发时特别重要,同样是展示一个模型,不同材质可能带来成倍的帧率差距。
2.2 六大材质逐一拆解
MeshBasicMaterial
它就像一张贴纸,不接受光照,不会产生阴影,只显示颜色或贴图。适合用于调试场景——先用它确认模型加载是否正确,再切换到标准材质调整光影效果。也适合制作不真实感的UI特效、辅助线、天空盒子等。
MeshLambertMaterial
这是最简单的受光材质,只计算漫反射(diffuse),表面光线的亮度会随法线方向变化,但不会出现高光镜面反射。因此它适合表现粗糙表面:粉笔、混凝土、布料。Lambert材质的性能开销很小,在低端设备上如果不需要高光,用它会是不错的选择。
MeshPhongMaterial
在Lambert的基础上增加了镜面反射(specular)计算,可以通过shininess控制高光范围,通过specular颜色控制高光色调。这是早期three.js项目最常见的材质。虽然现在有了更真实的Standard材质,但在做卡通渲染或需要精确控制高光的风格化效果时,Phong反而更好使。
MeshStandardMaterial
这是当前最主流、也是我做项目默认选择的材质。基于PBR模型,核心参数就两个:metalness(金属度)和roughness(粗糙度)。
- metalness为0表示非金属,为1表示纯金属。
- roughness从0到1,0是抛光的镜面,1是完全粗糙的哑光表面。
用这两个参数,可以模拟出绝大部分真实材质的感觉。此外,Standard材质的高度贴图(bumpMap)、法线贴图(normalMap)、环境遮挡贴图(aoMap)、自发光贴图(emissiveMap)等通道,为细节表现提供了很大的空间。
MeshPhysicalMaterial
它是Standard的"增强版"。除了基础参数,额外支持clearcoat(清漆层强度)、clearcoatRoughness(清漆层粗糙度)、sheen(织物边缘光泽)、ior(折射率)等参数。用来表现汽车漆面、玉石、有镀膜的眼镜镜片等复杂光学效果时,Physical Material是首选。它的渲染开销也更大,适合对画面质量要求较高且设备性能充裕的场景。
MeshToonMaterial
卡通渲染专用材质。它的漫反射颜色不是连续的,而是通过梯度贴图产生明暗分阶的效果,制造出类似漫画的硬阴影边界。做风格化项目时我经常用,价格便宜,效果也稳定。
2.3 材质公共参数与常见坑
有几个参数几乎对所有材质都适用,但容易被忽略。
第一个是side(渲染面)。默认值是THREE.FrontSide,只渲染正面。如果模型面的法线方向错了,或者你在做玻璃、纸张这类双面可见的物体,就会看到"少了一半"的现象。这个时候需要设置side: THREE.DoubleSide。
第二个是transparent与opacity。很多新手直接设置opacity发现没有效果,原因是没有把transparent设为true。需要提醒一下,开启透明后three.js要额外进行透明排序,会带来一定的性能开销,大量透明物体叠加时还可能出现穿插错误,所以要尽量少用。
第三个是visible。材质继承自Object3D,有visible属性。如果在调试时想快速隐藏某个物体,不需要删除它,直接设material.visible = false即可。
第四,depthWrite。当多个透明物体相互穿插时,调整深度写入能解决排序错误的问题,但副作用是可能产生绘制顺序异常。这是进阶技巧,新手阶段需要了解有这个参数即可。
3. 光照系统拆解:光源类型与场景适配
three.js里光源的种类并不复杂,但每种光源的适用场景和计算方式差异很大。布光的本质是在"艺术效果"和"性能代价"之间找平衡。
3.1 四种基础光源的物理直觉
AmbientLight(环境光):没有方向,均匀照亮所有物体,相当于一个"保底光"。它不产生阴影,没有衰减,作用是防止场景中背光面完全变黑。它的强度不宜过高,我一般控制在0.3-0.5左右,太强的Environment光会让画面发灰,失去立体感。
DirectionalLight(平行光):光的方向是固定的,类似于太阳。无论物体离光源多远,光照强度不变。平行光可以产生阴影,是室外场景的主力光源。它在场景里更适合用做"主光"。
PointLight(点光源):从某个点向四周辐射,像灯泡。它有两个核心参数:距离(distance)和衰减(decay)。distance控制光照的影响范围,超出范围之后光强降为0;decay控制光强随距离衰减的速度。点光源的阴影比较柔和,但计算量比平行光大。
SpotLight(聚光灯):在点光源的基础上增加了方向性和照射角度。angle控制照射范围(锥形角度),penumbra控制边缘从亮到暗的过渡范围。聚光灯很适合做舞台灯光、汽车大灯这类效果。
3.2 光源参数里最容易被忽略的细节
光源的强度单位让人困惑过一阵子。在three.js较早版本和较新版本里,光照强度默认单位并不完全一致。现代版本中,直接在构造函数里传入强度参数(如new THREE.PointLight(0xff0000, 50))。物理单位校准(useLegacyLights = false)之后,PointLight强度数值的含义变得更接近实际功率,实际操作里不用太纠结单位,记住"数值越大越亮,配合衰减调节"就够用。
另一个高频问题:光源放在场景里没有效果。排查思路很简单——先确认材质是否受光,再用DirectionalLight或AmbientLight排除光源类型问题,最后检查光源的position是否真的在物体可见范围内。
position这个变量也容易忽视。DirectionalLight只关心target,也就是光的方向,它自身的位置不影响角度计算。而SpotLight和PointLight的position就是发光点的位置,如果忘了把position设置到物体附近,场景当然一片黑。
**HemisphereLight(半球光)**也值得一提。它模拟天空和地面的环境色,配合不同颜色值可以迅速营造出自然的光照氛围。它在室外场景中做辅助光非常好用,能有效避免背光面死黑。
3.3 阴影:光源、材质、渲染器的三方配合
阴影是让场景立体感大幅提升的关键,同时也是新手最容易卡住的地方。three.js默认不开启阴影,你需要做三件事才能让阴影落地:
第一步,renderer.shadowMap.enabled = true。这是渲染器层面的总开关。
第二步,光源设置castShadow = true。平行光、点光源、聚光灯都支持投射阴影,AmbientLight和HemisphereLight不支持。
第三步,物体上需要区分"投影体"和"接收体"。投射阴影的物体设castShadow = true,接收阴影的地面或桌面设receiveShadow = true。
还有一个细节:MeshBasicMaterial虽然能看到阴影吗?答案是看不到的。因为Basic材质不参与光照计算,也不接收阴影投影。换句话说,接收阴影的材质必须是能响应光照的材质。
阴影效果的细节参数也值得花时间调一调。shadow.mapSize控制阴影贴图分辨率,默认是512x512,想更精细就设2048或更高,但内存和计算开销会上升。shadow.bias用于修正"阴影条纹"(shadow acne)问题,通过微调可以消除阴影斑点,但设得太大又会出现"阴影偏移"的悬浮感。这些参数不是一次就能调好的,多试几组值,观察边缘变化,直到找到不大不小刚好合适的值。
4. 实战:从零搭一个看得过去的静物场景
理论说再多,不如跑一个完整的场景。这一节我用一个"桌面静物"的示例,把材质选择和布光流程完整过一遍。场景很简单:一个金属质感的水杯放在粗糙的木桌上,背后是柔和的环境余光。这个案例覆盖了金属度、粗糙度、阴影、对比度这几个核心概念。
4.1 场景准备
先搭好场景、相机和渲染器基础设施。这一部分相信大家已经很熟悉,我只强调几个关键选项。
const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0;开头几步是关键:
- type选择PCFSoftShadowMap,阴影边缘会比较自然地柔和。
- outputColorSpace设置为sRGB色彩空间,避免出现整体偏灰、泛白的问题。这是拿颜色贴图场景一个经常被忽视的细节。
- toneMapping选择ACESFilmicToneMapping可以提升画面的对比度和色彩层次感,让亮部不过曝。这是画面质感提升的一个重要技巧。
4.2 材质:用参数描摹"真实感"
先做桌面。木桌面不是高反光材质,用MeshStandardMaterial,roughness设在0.85左右,金属度0。这样表面不会有明显高光,但纹理细节还是能看出质感。
然后是水杯。假设是不锈钢材质,metalness设到0.95,roughness设到0.2。这样杯身上会出现明显的环境反射和镜面高光。如果杯子表面还想要一层清漆感,可以换用MeshPhysicalMaterial,给clearcoat设置一个0.3左右的数值,效果会更肉一点。
const tableMaterial = new THREE.MeshStandardMaterial({ color: 0x8b7355, roughness: 0.85, metalness: 0, }); const cupMaterial = new THREE.MeshPhysicalMaterial({ color: 0xc0c0c0, metalness: 0.95, roughness: 0.2, clearcoat: 0.3, });贴图的事先放一边,纯颜色材质先把光影关系理清楚,后续替换贴图只是加一个texture的事。
4.3 布光:从主光、辅光到轮廓光
室内静物场景,我习惯的布光顺序是主光→辅光→轮廓光。
主光用DirectionalLight,模拟窗外进来的阳光,位置在物体左上方,角度稍微向下。强度设置在2.0到3.0之间,打开castShadow。这样的好处是阴影方向单一、清晰,便于构建主体的体积感。
辅光用HemisphereLight,天空颜色偏冷蓝,地面颜色偏暖棕,强度0.3到0.5。它的作用是填充主光产生的阴影区域,防止纸面下黑成一片,同时增加环境的氛围色。
轮廓光用SpotLight或PointLight,放在物体后方偏上,用来勾出杯子的边缘亮线,让主体从背景中"跳"出来。强度不需要大,0.5到1.0足够,太强会喧宾夺主。
const mainLight = new THREE.DirectionalLight(0xfff5e6, 2.5); mainLight.position.set(4, 6, 3); mainLight.castShadow = true; mainLight.shadow.mapSize.set(2048, 2048); scene.add(mainLight); const fillLight = new THREE.HemisphereLight(0xaaccff, 0x8b7355, 0.4); scene.add(fillLight); const rimLight = new THREE.SpotLight(0xffffff, 1.0); rimLight.position.set(-3, 4, -4); scene.add(rimLight);调参的过程我建议按这个顺序来:先单独开主光,把整体的明暗方向确定下来;再开辅光,调整暗部亮度直到细节可见但不抢戏;最后加轮廓光,只为了提亮边缘。这样做不会出现"哪个光源都想要,最后画面过曝"的情况。
5. 调试与避坑:我踩过的那些材质光照坑
最后这部分,直接上干货。我在实际项目里踩过的坑,整理成几条规律,方便大家对照排查。
5.1 画面发灰或过曝,多半是色彩空间和色调映射的问题
很多新手刚学会打光,调了半天灯光,画面却总是灰蒙蒙的,对比度上不去。原因往往是渲染器的outputColorSpace没有设置,默认的线性色彩空间会让颜色显得"闷"。把outputColorSpace设成SRGBColorSpace之后,立刻会通透很多。
如果画面高光区域容易糊成一片、缺乏层次,加上ACESFilmicToneMapping,再把toneMappingExposure调到0.8到1.2之间,高光和暗部都能保留更多的细节。想追求不同的风格,可以试试ReinhardToneMapping或NeutralToneMapping,效果差异明显,多做几组对比就会有自己的偏好。
5.2 阴影出现条纹、漏光或抖动
阴影脏乱多因为两个原因。一个是shadow.bias参数设置得不好。偏小容易出现和物体表面"打架"的条纹阴影,偏大则物体阴影会"悬空"或歪斜。正确做法是把bias先设成0.01左右,观察阴影边缘,上下微调直到干净。
另一个是阴影相机的范围问题。DirectionalLight的shadow.camera范围默认是-5到5,如果你把一个比较大的场景放进去,物体可能超出阴影相机范围,导致阴影不断跳动或消失。解决办法是根据场景大小设置shadow.camera的left、right、top、bottom值。这里用一张表格总结:
| 现象 | 常见原因 | 调整方向 |
|---|---|---|
| 阴影表面有黑色小斑点 | shadow.bias过小 | 增大bias(如0.01) |
| 阴影悬浮、和物体分离 | shadow.bias过大 | 减小bias |
| 远处阴影突然消失 | shadow.camera范围不够 | 扩大shadow.camera边界 |
| 阴影边缘太锯齿 | shadow.mapSize太低 | 提高mapSize到2048或更高 |
5.3 性能优化:光源数量和材质档位的取舍
最后说性能。点光源和聚光灯的阴影计算非常昂贵。一个场景里同时有三个以上带阴影的PointLight,帧率基本会明显下滑。移动端项目里更要克制,我有段时间为了画面效果在一个房间里放了六个SpotLight,结果手机直接变成幻灯片。
优化思路有几个,按性价比排序:
- 用DirectionalLight替代部分PointLight和SpotLight,平行光阴影性价比最高。
- 没有必要的灯光不要开castShadow。
- 固定场景可以把光照信息烘焙到贴图里,运行时不再动态计算,这是性能提升幅度最大的一招。
- 不用PBR材质的地方就退回到MeshLambertMaterial或MeshPhongMaterial,肉眼差别不大,性能差异却很可观。
- 环境贴图(如HDR环境映射)能同时提升Standard材质的反射细节,代价只算一次采样,往往比多加几个辅助光源更划算。
我个人的经验是一般场景建议的配置是"1个主光带阴影 + 1个环境光/半球光辅助 + 1个轮廓光不带阴影"。这个配置既能把物体的立体感和材质质感表现出来,性能也不会受到明显拖累。真需要更多灯光氛围时,再做针对性妥协。
材质与光照的掌握其实没有太多捷径,理解PBR参数的含义,搞懂光源类型的物理直觉,剩下的就是多调多对比。把上面这些参数逐一改变、观察画面变化的规律记在心里,以后再遇到任何"画面不对"的情况,你都能很快定位到问题所在。