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
2
3
4
5
6
7
MeshStandardMaterial
├─ 基础 color roughness metalness map normalMap ...
└─ MeshPhysicalMaterial 追加:
├─ 清漆层 clearcoat clearcoatRoughness clearcoatNormalMap
├─ 透射层 transmission thickness ior attenuationColor attenuationDistance
├─ 高光层 specularIntensity specularColor
└─ 特殊外观 sheen iridescence anisotropy

四组默认全是关的(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
2
3
4
5
6
7
8
9
10
11
    入射光               清漆层表面反射(无色高光)
\ /
\ /
┌───────\─────────────────/───────┐ <- 清漆层 clearcoat
│ \ / │ roughness 由 clearcoatRoughness 决定
│ \ / │
│ v / │
├─────────────────────────────────┤ <- 色漆层(color + roughness + metalness)
│ │ │
└──────────────┘ ┘
底色反射(带颜色)

这就是清漆必须是一个独立的层的原因:它自己的粗糙度和底色那层的粗糙度是两件事。底色可以很哑光,同时清漆很亮——日常说的”车漆发乌””漆面花了”,讲的都是上面那层的状态。

1
2
3
4
5
6
7
8
// 车身漆面
const carPaint = new THREE.MeshPhysicalMaterial({
color: 0x1b3a6b, // 底色:深蓝
metalness: 0.9, // 车漆里带金属颗粒,偏金属
roughness: 0.4, // 底色这层是哑的
clearcoat: 1, // 开启清漆层
clearcoatRoughness: 0.01, // 清漆极其光滑
})

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
2
3
4
5
6
7
8
9
10
11
// 车窗玻璃
const glass = new THREE.MeshPhysicalMaterial({
color: 0xffffff,
metalness: 0,
roughness: 0.05,
transmission: 1, // 全透射
thickness: 0.5, // 有厚度,才会真的折射
ior: 1.5, // 玻璃的折射率
attenuationColor: new THREE.Color(0x88c8ff), // 穿过来的光偏蓝
attenuationDistance: 0.8, // 多厚开始明显染色
})

四个参数各管一件事:

参数 管什么 默认 要点
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
2
3
4
5
6
7
8
9
10
11
12
13
14
             入射光
|
v
┌──────────────────────────────┐
│ ior 1.5:进玻璃时弯一次 │ ← 一部分在这里直接反射走
│ ↘ │
│ ↘ 穿过厚度的过程中 │ attenuationColor 在这段路程里生效,
│ ↘ 被吸收、被染色 │ 走得越远越接近 attenuationColor
│ ↘ │
│ ↘ 出玻璃再弯一次 │
└──────────────────────────────┘
|
v
背后的画面(偏蓝)

然后是最容易踩的一个坑。 透射率和不透明度看着像是两个可以配合着调的旋钮,很容易顺手两个都写:

1
2
3
4
const glass = new THREE.MeshPhysicalMaterial({
transmission: 0.9,
opacity: 0.5, // ← 文档明确不建议
})

文档对 transmission 的说明里有一句:

When transmission is non-zero, opacity should be set to 1.

原因不复杂:透射材质就算全透,表面那层反射是保留的——这正是玻璃能同时”看到后面的东西”又”看到表面反光”的原因。把 opacity 压下去,等于在透射之外又叠了一道 alpha 混合,把包括反射在内的整体一起削。玻璃会变得发闷、发灰,背后的画面被削了两层。

结论:transmission 非 0 时 opacity 保持 1。 想让玻璃更透,动 transmission 和 attenuationDistance,不要动 opacity。

4. 前大灯灯罩:透,但反光不能丢

灯罩和车窗是同一组参数,取值方向相反:

1
2
3
4
5
6
7
8
9
// 前大灯灯罩
const headlightCover = new THREE.MeshPhysicalMaterial({
color: 0xffffff,
metalness: 0,
roughness: 0.02, // 比玻璃更光
transmission: 0.95, // 留一点点,让灯罩本身看得见
ior: 1.45,
// thickness 不设,保持默认 0
})

区别只有一条:不设 thickness(保持默认 0)。灯罩是一层薄壳,光穿过它几乎不走路程,也就谈不上体积。如果把 thickness 按玻璃那样设成 0.5,渲染器会把它当成一块实心厚玻璃,灯罩内部会多出一层不属于它的折射和染色——本来只是一片塑料,看起来变成了一块水晶。

ior 留在 1.4–1.5 之间是为了保住表面那点反射。灯罩之所以看起来是”罩”而不是一个洞,全靠这点反射把轮廓勾出来;把 ior 压到 1,灯罩基本就消失了。

5. 轮毂:这一条其实不属于 physical

轮毂和金属装饰条要的是”亮面金属”,这个 MeshStandardMaterial 就有:

1
2
3
4
5
6
// 轮毂 / 金属装饰条
const wheelMetal = new THREE.MeshPhysicalMaterial({
color: 0xcccccc,
metalness: 1, // 纯金属
roughness: 0.15, // 抛光的,不是镜面
})

放在这篇里,是因为同一个车模型上四种材质是并排写的,轮毂最容易被顺手也加一句 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
2
3
4
5
6
7
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'

const pmrem = new THREE.PMREMGenerator(renderer)
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture

// scene.environment 和 scene.background 是两件事:
// 只想照亮、不想换背景的话,不要动 scene.background

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)。这些属于场景对象和交互,改天单独写。

参考

作者

wangyawei

发布于

2026-09-14

更新于

2026-09-14

许可协议