threejs使用pbr物理材质
MeshStandardMaterial 是 three.js 里默认走 PBR 的材质。它的着色基于 metallic-roughness 工作流——也就是 glTF 用的那一套——光照响应是物理化的,跟 MeshPhongMaterial 那种靠高光指数凑出来的效果不是一个路子。
它一共两个核心旋钮:
| 参数 | 范围 | 含义 |
|---|---|---|
metalness |
0–1 | 0 是电介质(塑料、木头、漆面、玻璃),1 是纯金属。中间值基本只在烘焙贴图里出现 |
roughness |
0–1 | 0 是镜面,高光收成一个点;1 完全漫反射,没有高光 |
两个参数,配上一张 color、一张法线贴图,已经能覆盖场景里绝大部分东西:塑料外壳、木头桌面、没上漆的金属、混凝土、地面。日常够用了。
但它们有一条共同的边界——描述的全都是”同一个表面”。roughness 说的是这个表面有多粗糙,metalness 说的是这个表面是不是金属,color 说的是这个表面是什么颜色。三个答案指向的是同一层。
一辆车不是这么回事:
- 车漆是两层。底下是带颜色的色漆,上面覆一层无色透明的清漆。清漆有自己的粗糙度,跟底色那层完全是两回事——底色可以很哑,清漆同时很亮。
- 玻璃不是一个”表面”,是一个”体积”。光要穿过去,还要在穿过的那段路程里被染色。
- 轮毂是纯金属,没有漫反射,看到的全是反射来的环境。这条 standard 能表达,但它会牵出环境贴图的问题。
前两条 MeshStandardMaterial 表达不了:它没法说”这个表面之上还盖着另一层,那层有自己的粗糙度”,也没法说”光会穿过这个表面,并在里面走一段”。
MeshPhysicalMaterial 就是来补这两块的。
1. 它比 MeshStandardMaterial 多出什么
MeshPhysicalMaterial 继承自 MeshStandardMaterial,roughness / metalness / map / normalMap 那一套原样保留,多出来的是四组”附加层”:
1 | MeshStandardMaterial |
四组默认全是关的(clearcoat、transmission、sheen、iridescence、anisotropy 默认都是 0)。这不是随手取的默认值,而是在表达一件事,文档里写得很直白:
Most effects are disabled by default, and add cost as they are enabled. For best results, always specify an environment map.
前半句是代价,后半句是这篇后面要反复回到的点:这些效果有一半靠环境反射撑着,没有环境贴图,它们要么看不见,要么是黑的。
车辆可视化里真正用得上的,是第一、二组,外加 standard 本来就有的 metalness。
2. 车漆:clearcoat 和 clearcoatRoughness
车漆是分两层的:底下是一层带颜色的色漆,上面覆一层无色的清漆。光打上去,一部分在清漆表面直接反射掉,另一部分穿过清漆、打进色漆再反射回来。
1 | 入射光 清漆层表面反射(无色高光) |
这就是清漆必须是一个独立的层的原因:它自己的粗糙度和底色那层的粗糙度是两件事。底色可以很哑光,同时清漆很亮——日常说的”车漆发乌””漆面花了”,讲的都是上面那层的状态。
1 | // 车身漆面 |
clearcoat 是清漆层的强度(0–1,默认 0),clearcoatRoughness 是清漆层自己的粗糙度(默认 0)。
clearcoatRoughness: 0.01 是新车:高光收成一个锐利的小点,漆面像镜面一样把环境里的亮部清晰地勾出来。
调到 0.5 就变成”略微磨损的漆面”:高光被摊成一大片模糊的亮区——反光还在,但不清晰了;而颜色没变。
有个容易搞反的地方:clearcoat 加的是”一层无色的膜”,不是”让车漆变亮”。开到 1,底色那个蓝几乎不动,变的是高光的形状和锐度。所以想调”漆面新不新”调 clearcoatRoughness,想调”上没上过清漆”调 clearcoat。
如果清漆上的划痕有方向性,可以用 clearcoatNormalMap 单独给清漆层一张法线贴图——它是独立的,不影响底色的 normalMap。另外,clearcoatRoughnessMap 读的是绿通道。
3. 玻璃:transmission 那一组
玻璃要的是”光穿过去”。这件事 MeshStandardMaterial 做不到——它只能靠 transparent + opacity 做 alpha 混合,等于把玻璃当成一张半透明的图片盖在背景上。alpha 混合出来的玻璃:
- 不折射,看起来像一块有色塑料片
- 穿过厚玻璃时不会被染色
- 反射和透明度只能在数值上互相折中
transmission 是另一条路:材质真的去采它背后的画面,再按折射和吸收的规则算一遍。
1 | // 车窗玻璃 |
四个参数各管一件事:
| 参数 | 管什么 | 默认 | 要点 |
|---|---|---|---|
transmission |
有多少光穿过,而不是被反射/吸收 | 0 |
0 不透,1 全透;是光学透明,不是 alpha 透明 |
ior |
折射率,决定弯折程度和边缘反射强度 | 1.5 |
1.5 正好是玻璃;水 1.33(three.js 允许 1.0–2.333) |
thickness |
表面之下那层”体积”有多厚 | 0 |
0 是薄壁,不做体积折射;要折射必须 > 0 |
attenuationDistance |
光在材质里走多远开始被吸收 | Infinity |
单位是世界坐标,必须 > 0 |
attenuationDistance 的默认值 Infinity 很关键,它的意思是”光走多远都不衰减”。所以只设 attenuationColor 而不设 attenuationDistance,玻璃是不着色的——颜色配好了却看不出效果,坑就在这儿。要让蓝玻璃真的发蓝,得把 attenuationDistance 调到一个跟模型尺度匹配的值(上面的 0.8 是按这个车模型的单位给的,不是通用值)。文档对 thickness 的原话也可以顺手记一下:值为 0 时材质是 thin-walled(薄壁),否则它是 volume boundary(体积边界)。
1 | 入射光 |
然后是最容易踩的一个坑。 透射率和不透明度看着像是两个可以配合着调的旋钮,很容易顺手两个都写:
1 | const glass = new THREE.MeshPhysicalMaterial({ |
文档对 transmission 的说明里有一句:
When transmission is non-zero, opacity should be set to 1.
原因不复杂:透射材质就算全透,表面那层反射是保留的——这正是玻璃能同时”看到后面的东西”又”看到表面反光”的原因。把 opacity 压下去,等于在透射之外又叠了一道 alpha 混合,把包括反射在内的整体一起削。玻璃会变得发闷、发灰,背后的画面被削了两层。
结论:transmission 非 0 时 opacity 保持 1。 想让玻璃更透,动 transmission 和 attenuationDistance,不要动 opacity。
4. 前大灯灯罩:透,但反光不能丢
灯罩和车窗是同一组参数,取值方向相反:
1 | // 前大灯灯罩 |
区别只有一条:不设 thickness(保持默认 0)。灯罩是一层薄壳,光穿过它几乎不走路程,也就谈不上体积。如果把 thickness 按玻璃那样设成 0.5,渲染器会把它当成一块实心厚玻璃,灯罩内部会多出一层不属于它的折射和染色——本来只是一片塑料,看起来变成了一块水晶。
ior 留在 1.4–1.5 之间是为了保住表面那点反射。灯罩之所以看起来是”罩”而不是一个洞,全靠这点反射把轮廓勾出来;把 ior 压到 1,灯罩基本就消失了。
5. 轮毂:这一条其实不属于 physical
轮毂和金属装饰条要的是”亮面金属”,这个 MeshStandardMaterial 就有:
1 | // 轮毂 / 金属装饰条 |
放在这篇里,是因为同一个车模型上四种材质是并排写的,轮毂最容易被顺手也加一句 clearcoat: 1 或者 transmission: 0.3——前者等于给金属又刷了一层漆,后者会让一块实心轮毂变成半透明。金属不需要清漆也不需要透射,它要的是 metalness 拉满 + roughness 压低。
两个关于 metalness = 1 的常识,但经常被忽略:
- 金属没有漫反射。
metalness到 1 之后,color的性质就变了:它不再是”物体本身的颜色”,而是”反射被染成什么颜色”。一个metalness: 1, color: 0xcccccc的球在纯白光下是银灰;把color改成0xffcc00,它就成了金色。这不是”金色漆”,是反射来的光被染成了金色。 - 纯金属在没有环境可反射的时候是黑的。 因为它只剩反射,没有漫反射兜底。场景里没有环境贴图,
metalness: 1的轮毂就是一坨黑。这条直接把人推到下一节。
6. 这几个参数一半靠环境贴图撑着
清漆的高光、金属的反射、玻璃的折射——追问到底是同一件事:采周围环境。没有环境贴图,clearcoat 在等一个不存在的光源,metalness: 1 是黑的,transmission 采出来的是空背景。
这就是文档那句 “always specify an environment map” 的分量。最小做法是用 RoomEnvironment 现造一个:
1 | import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js' |
PMREMGenerator 这一层不能省:环境贴图必须先卷积成带 mip 的粗糙度层级,roughness 不同的地方才能采到对应模糊程度的环境。直接把一张 HDR 塞进 scene.environment,高光会又硬又脏。
真实车模通常自带环境贴图,或者用 RGBELoader 加载 HDR 全景图,那是另一个话题。这里只需要场景里有东西可采。
7. 顺带说:glTF 里的车漆本来就是这套
如果车模是 glTF,车身那层清漆可能根本不用手写——KHR_materials_clearcoat、KHR_materials_transmission、KHR_materials_ior、KHR_materials_volume 这几个扩展,GLTFLoader 会自动映射成 MeshPhysicalMaterial 上对应的属性。
所以会出现这种情况:模型加载进来看起来就是车漆,打印材质发现确实是 MeshPhysicalMaterial,clearcoat 也已经是 1,而你一行材质代码都没写。要改直接改就行——属性就在那儿,只是来源是文件不是代码。
8. 代价
文档开头那句代价不是客套话:
- 每像素开销比其它材质高,而且高的那部分只在你开启的效果上。
transmission不只是采样,还要多渲染一趟。 alpha 混合只需要拿现成的画面来用,透射材质得真的看到背后——渲染器要把场景先渲染进一张离屏 target,透射材质再去采样它。所以场景里一旦有透射材质,这一帧增加的工作量就不是”多画几个面”的量级。- 别整场景统一换成
MeshPhysicalMaterial。 一个车模型是几十上百个 mesh,车漆、玻璃、灯罩、轮毂这四处用 physical 就够了,内饰、地面、背景继续用MeshStandardMaterial。
9. 四个部位放一起
| 部位 | 关键属性 | 参考值 | 一句话 |
|---|---|---|---|
| 车身漆面 | clearcoat + clearcoatRoughness |
1 / 0.01 |
清漆是独立的一层,只管高光不管颜色 |
| 车窗玻璃 | transmission ior thickness attenuationColor/Distance |
1 / 1.5 / 0.5 / 配一对 |
thickness > 0 才有体积折射;不配 attenuationDistance 不着色 |
| 前大灯灯罩 | 同上,但不设 thickness |
0.95 / 1.45 / — |
薄壳,设了厚度会变水晶 |
| 轮毂 / 装饰条 | metalness + roughness(standard 就有) |
1 / 0.15 |
不要加 clearcoat,不要开 transmission |
真正要记住的只有三件事:清漆是盖在底色之上、有自己粗糙度的一层;玻璃的”透”要用 transmission,不是 opacity;这四个效果都要求场景里有东西可反射——环境贴图。
另外提一句,这套笔记里还有几块不在本篇范围内:车的旋转和颜色切换用的是 tween.js 补间动画(和材质无关),车门开关是射线投射器做拾取(交互),车灯的发光效果是 Lensflare + LensflareElement(来自 three/addons/objects/Lensflare.js)。这些属于场景对象和交互,改天单独写。
参考
- MeshPhysicalMaterial 文档:https://threejs.org/docs/#api/en/materials/MeshPhysicalMaterial
- 官方示例
webgl_materials_physical_clearcoat、webgl_materials_physical_transmission、webgl_loader_gltf_clearcoat - glTF 材质扩展:
KHR_materials_clearcoat、KHR_materials_transmission、KHR_materials_ior、KHR_materials_volume - tween.js:https://github.com/tweenjs/tween.js
threejs使用pbr物理材质