第 19 章:PBR 材质属性与 Fragment 进阶
第 19 章:PBR 材质属性与 Fragment 进阶 —— 自定义 3D 材质的五个旋钮
学习目标:理解 PBR 五大核心属性(ALBEDO / ROUGHNESS / METALLIC / EMISSION / AO)的直觉含义,能用 fragment() 函数自定义 3D 材质外观,掌握从"贴图驱动材质"到"纯代码生成材质"两种实战方式,为第 20 章 light() 光照函数打下"材料准备"基础。
场景搭建:新建 MeshInstance3D + SphereMesh,挂载 ShaderMaterial → 新建 Shader(shader_type spatial)。光滑球体是最适合演示材质属性变化的几何体。
19.1 为什么需要 PBR?
在真实世界里,一辆红色汽车的表面不仅仅是"红色的"。擦得锃亮的引擎盖能反射天空的云朵,亚光的车门把手则把光线均匀散开。如果手写所有这些细节,每个材质都要写几百行光照代码。
PBR(Physically Based Rendering,基于物理的渲染)把这件事颠倒过来:你只负责描述"这个表面是什么样的",引擎负责计算"光照到这个表面后会发生什么"。
Godot 4 默认使用 PBR 渲染管线。在 spatial shader 中,fragment() 是你的"材料准备台"——你往上面摆 ALBEDO、ROUGHNESS、METALLIC 这些原材料。light() 是"后厨灶台"——它拿这些材料去和每个光源"炒菜"。
本章和第 20 章的分工:本章只讲 fragment() 里的"材料准备",不写 light()。这样你可以先看到每个 PBR 属性单独变化的效果,第 20 章再打开 light() 灶台做自定义光照。
在进入 PBR 材质属性之前,有一个更基础的问题:光照系统靠什么判断一个像素该有多亮?
答案是法线(Normal)。法线是垂直于表面的单位向量,它告诉光照系统"这个表面朝向哪里"。光照方向与法线方向越一致,表面越亮——这是整个光照计算的基石,也是第 20 章一切内容的"引线"。
面法线 vs 顶点法线
3D 模型由三角面片组成。每个三角面有自己的"面法线"(Face Normal)——垂直于三角平面的方向。如果渲染时用面法线做光照,不同三角面之间亮度突变,模型看起来像宝石切割面——这就是 Flat Shading。
Smooth Shading 的做法不同:给每个顶点分配一个"顶点法线"(Vertex Normal),在光栅化时,三角形内部像素的法线由三个顶点法线插值得到。所以即使几何面是平的,法线也能平滑过渡——低面模型看起来像高面模型。
对于 SphereMesh 等内置几何体,Godot 在顶点数据中自动储存了法线。在 shader 中,VERTEX(vertex 函数)和 NORMAL(fragment / light 函数)都是准备好、可直接读取的向量。你不需要手动算法线,但需要理解它们"是什么"和"从哪来"。
下面我们来逐步讲解PBR 的核心属性。
19.2 ALBEDO —— 物体的"本色"
ALBEDO 解决一个问题:这个表面在纯白光照下,呈现什么颜色?
生活例子:一张白纸在日光灯下看起来是白的,在夕阳下看起来是橙的——但纸的"本色"没变。ALBEDO 描述的就是这个"本色"——不包含任何光照信息的固有颜色。
变量介绍:ALBEDO 是一个 vec3,表示表面的固有色。它不包含任何光照信息——暗处的 ALBEDO 和亮处的 ALBEDO 是同一个值。光照系统会拿这个值和光源颜色相乘。
为什么是 vec3 而不是 vec4?3D 物体的透明度通过 render_mode + ALPHA 或 ALPHA_SCISSOR 控制,不走 ALBEDO 的 alpha 通道。
最简代码:一个红色的球
这些代码发生了什么?fragment() 把每个像素的 ALBEDO 都设为红色。Godot 看到你写入了 ALBEDO,就用自己的 PBR 光照系统(默认 ROUGHNESS=1.0、METALLIC=0.0)完成渲染。
实际用途:ALBEDO 可以来自纹理。
uniform sampler2D base_texture : source_color, repeat_enable;
void fragment() {
ALBEDO = texture(base_texture, UV).rgb;
}
texture() 从纹理中按 UV 坐标采样,返回 vec4,.rgb 取前三个分量给 ALBEDO。这是 3D 游戏中最常见的用法——模型师画的贴图直接喂给 shader。
19.3 ROUGHNESS —— 从镜子到墙壁
ROUGHNESS 解决一个问题:光线在这个表面上,是集中反射还是四处散开?
生活例子:抛光的镜面反射出清晰的倒影,磨砂手机壳的反射模糊不清,白墙没有任何反射。ROUGHNESS 就是控制这个"模糊程度"的旋钮。
变量介绍:ROUGHNESS 是一个 float,范围 0.0 到 1.0:
| 值 | 直觉类比 | 高光表现 |
|---|---|---|
| 0.0 | 抛光的镜面 | 锐利的小亮点 |
| 0.3 | 光滑塑料 | 中等大小的高光 |
| 0.7 | 磨砂金属 | 宽大模糊的高光 |
| 1.0 | 白墙/布料 | 完全没有集中反射 |
关键理解:ROUGHNESS 同时控制两件事——反射的模糊程度和反射的亮度。越粗糙的表面,光线被打散到越多方向,单个方向的反射就越暗。
最简代码:
实际用途:用 UV 做纵向渐变
void fragment() {
ALBEDO = vec3(0.3, 0.3, 0.5); // 蓝灰色
ROUGHNESS = UV.y; // 底部光滑(0) → 顶部粗糙(1)
METALLIC = 0.0;
}
UV.y 在球体顶部是 1.0,底部是 0.0。所以这段代码让球体底部像镜子、顶部像磨砂——一个拿在手里的球,手心握持处确实比顶端更"光滑"。
19.4 METALLIC —— 金属与非金属的区别
METALLIC 解决一个问题:这个表面是金属还是非金属? 这直接决定了高光的颜色。
生活例子:一个红色的塑料水壶和一个红色的金属水壶,漫反射部分颜色相同,但高光完全不同——塑料的高光是白色的,金属的高光带红色。这就是 METALLIC 控制的关键差异。
变量介绍:METALLIC 是一个 float,范围 0.0 到 1.0。在物理世界中,金属和非金属的反射行为有本质区别:
| 非金属(塑料/木材/皮肤) | 金属(铁/金/铜) | |
|---|---|---|
| 漫反射颜色 | ALBEDO 的颜色 | 没有漫反射 |
| 高光颜色 | 白色(光源的颜色) | ALBEDO 的颜色 |
| 吸收光线 | 部分吸收、部分漫反射 | 全部反射或全部吸收 |
最简代码:一个金属球
METALLIC=0.0 时,ALBEDO 的黄色只出现在暗面;高光部分是白色的。这是因为非金属的 specular 反射不吸收颜色。
METALLIC=1.0 时,整个球看起来都是金的——暗面对比度高(金属有更深的暗调),高光也带黄色(金属的 specular 反射被 ALBEDO 染色)。
实际用途:游戏中绝大部分材质 METALLIC 要么是 0、要么是 1。只有过渡区域(如生锈的边界、涂漆剥落处)才需要在 0 和 1 之间做渐变。这就是"metalness workflow"的核心——用黑色/白色遮罩纹理控制 METALLIC 的二进制开关。
19.5 EMISSION —— 自发光
EMISSION 解决一个问题:如何让物体自己发光,像霓虹灯、显示屏、岩浆一样?
生活例子:手机屏幕即使在全黑房间里也看得见,熔岩即使在白天也呈现刺眼的橙红色。这些发光不依赖外部光源——它们自己就是"光源"。
变量介绍:EMISSION 是一个 vec3,在所有光照计算完成后直接加到最终颜色上。它不参与任何光照计算——没有光源,它也会亮;有十个光源照它,它也不会更亮。
最简代码:
void fragment() {
ALBEDO = vec3(0.05, 0.05, 0.1); // 深色的基底
ROUGHNESS = 0.6;
EMISSION = vec3(1.0, 0.3, 0.0); // 橙色自发光
}
实际用途:
// 1. 高亮UI面板
EMISSION = vec3(0.2, 0.5, 1.0) * 2.0;
// 2. 呼吸灯(随时间脉动)
EMISSION = vec3(1.0, 0.5, 0.0) * (sin(TIME * 3.0) * 0.5 + 0.5);
// 3. 条纹发光(配合 UV 做图案)
float stripe = step(0.4, fract(UV.y * 10.0));
EMISSION = vec3(0.0, 1.0, 0.0) * stripe * 2.0;
重要提醒:EMISSION 只让物体本身看起来更亮。它不会向周围投射光照。如果要让自发光物体照亮旁边的物体,需要在场景中额外放置 Light3D 节点。Godot 4.3+ 支持 VoxelGI 和 SDFGI 的 emissive 贡献,但那是全局光照系统的功能,不是 shader 变量本身的功能。
19.6 AO —— 环境光遮蔽
AO 解决一个问题:物体的角落、缝隙、接触面应该更暗,因为环境光不容易照到这些地方。
生活例子:房间的墙角比墙面中央更暗,桌面的缝隙比桌面更暗。这些地方不是主光源照不到,而是"四面八方散射来的环境光"在这类区域更难抵达。
变量介绍:AO(Ambient Occlusion)是一个 float,范围 0.0 到 1.0。1.0 表示环境光完全到达这里;0.0 表示环境光被完全遮蔽。
最简代码:
void fragment() {
ALBEDO = vec3(0.6, 0.6, 0.6); // 灰色
ROUGHNESS = 0.5;
// 模拟 AO:球体两极更暗
float ao = 1.0 - abs(UV.y - 0.5) * 1.5;
ao = clamp(ao, 0.3, 1.0);
AO = ao;
}
实际用途:在真正的项目中,AO 通常来自 3D 软件烘焙的 AO 贴图,通过 texture() 采样到:
uniform sampler2D ao_map;
void fragment() {
AO = texture(ao_map, UV).r; // AO 贴图通常只读红色通道
AO_LIGHT_AFFECT = 0.3;
}
19.7 "不写就不编译" —— 引擎的优化机制
在进入下一部分之前,理解 Godot shader 编译器的这个关键特性可以帮你写出更高效的 shader。
规则:fragment() 中没有写入的 PBR 变量,对应计算路径不会被编译。引擎为每个材质生成"恰好够用"的着色器变体。
// 只写 ALBEDO —— 其他属性用引擎默认值,不产生额外计算
void fragment() {
ALBEDO = vec3(1.0, 0.0, 0.0);
}
// 写了 ROUGHNESS —— 引擎现在需要跑 roughness 相关计算
void fragment() {
ALBEDO = vec3(1.0, 0.0, 0.0);
ROUGHNESS = 0.3;
}
两个实际影响:
- 零开销自由:不用的属性完全没有性能代价。你不需要"手动关闭"什么——不写就是关。
- 清晰可读:看 fragment() 函数体就知道这个材质用了哪些 PBR 特性。没有隐藏的默认值在暗中生效。
以上我们介绍了 PBR 的五大核心属性。但 Godot 的 spatial shader 还提供了几个"进阶工具"——它们不是最基础的 PBR 参数,却能在特定场景下大幅提升材质表现力。
19.8 NORMAL_MAP —— 给低面模型加凹凸细节
NORMAL_MAP 解决一个问题:如何让一个只有几百面片的球体,看起来像有砖墙纹理、划痕、浮雕?
答案:每个像素修改它"认为"的法线方向。光照系统看到不同的法线,就会产生明暗变化——视觉上像凹凸,但实际上几何体没变。
法线贴图纹理的本质
法线贴图的每个像素储存的不是颜色,而是一个方向向量(法线)。RGB 三个通道分别对应切线空间的 X、Y、Z:
- R(红色)→ 法线在切线空间的 X 分量(左右偏转)
- G(绿色)→ 法线在切线空间的 Y 分量(上下偏转)
- B(蓝色)→ 法线在切线空间的 Z 分量(垂直表面)
法线贴图通常偏蓝色,因为大部分像素的法线都接近 (0, 0, 1) —— 即基本垂直于表面。
什么是切线空间(Tangent Space)?
法线贴图储存的法线方向是"相对于表面"的,不是绝对的。设想一个场景:给一个立方体的六个面各贴一张砖墙法线贴图。如果法线贴图里储存的是世界空间的法线("绝对朝上"),把立方体旋转 90 度后就全乱了——每旋转一度,都需要重新生成整张法线贴图。
切线空间解决了这个问题:法线贴图描述的是"偏离表面法线的微小偏转",而不是绝对的朝向。无论模型怎么旋转,只要表面本身的方向更新了,法线贴图的数据就自动跟随——引擎在背后完成坐标系转换。
什么是TBN 矩阵
TBN矩阵是一个转换矩阵,它的核心是一个 3×3 矩阵,由三个互相垂直的单位向量组成:
| 向量 | 含义 | 方向 |
|---|---|---|
| T (Tangent) | 切线 | 沿表面 U 方向(纹理的"右"方向) |
| B (Bitangent) | 副切线 | 沿表面 V 方向(纹理的"上"方向) |
| N (Normal) | 法线 | 垂直于表面 |
TBN 矩阵将法线贴图采样得到的切线空间法线 (r, g, b) 转换到世界空间:
其中 r、g、b 来自法线贴图的 RGB 通道(解压到 [-1,1] 后),分别表示法线在切线空间 X(左右)、Y(上下)、Z(垂直表面)方向的偏转量。
在 Godot 中你不需要手动算 TBN
好消息:Godot 的 spatial shader 在你写入 NORMAL_MAP 时,自动完成 TBN 变换。你只需要把法线贴图采样得到的切线空间法线(解压到 [-1,1] 范围后)赋值给 NORMAL_MAP,引擎负责后续的一切坐标转换。这也意味着 NORMAL_MAP 变量接收的是切线空间的法线,不是世界空间的。
对于需要手动操作 TBN 的高级情况(如视差贴图),Godot 提供了 TANGENT 和 BINORMAL 内置变量。可用 cross(NORMAL, TANGENT) 验证三者的正交关系——结果应与 BINORMAL 方向一致。
19.9 RIM —— 边缘光
RIM 解决一个问题:如何给物体加边缘光,让它从背景中"跳"出来?
我们会在第20章详细介绍边缘光,这里先简单介绍一下。RIM 的底层原理来自一条简单的公式:dot(NORMAL, VIEW)。
NORMAL 是表面法线——表面"朝向哪里";VIEW 是视线方向——你"从哪里看"。这两个单位向量的点积就是它们夹角的余弦:
- 正对表面(视线与法线平行):
dot(N, V) ≈ 1.0→ 你直视的地方 - 斜看表面(视线与法线接近垂直):
dot(N, V) ≈ 0.0→ 边缘 - 背对表面(视线在法线背面):
dot(N, V) < 0.0→ 你看不到的面
用 1.0 - dot(N, V) 翻转这个关系:正对时值接近 0(暗),边缘时值接近 1(亮)。这就是边缘光的全部数学——一条点积反转量,告诉你"这个像素离边缘有多近"。在第 20 章你会亲手写完整的 Fresnel + rim 公式。
在 Godot 4 的 spatial shader 中,RIM 是一个内建的边缘光变量。你不需要手写 Fresnel 公式——设置 RIM 的值,引擎自动计算边缘发光。
RIM = 0.0:无边缘光RIM = 1.0:完整边缘光(白色边缘)RIM_TINT:边缘光的颜色混合度(0=白色边缘,1=ALBEDO 颜色的边缘)
void fragment() {
ALBEDO = vec3(0.1, 0.1, 0.3); // 深蓝色
ROUGHNESS = 0.5;
RIM = 1.0;
RIM_TINT = 0.2; // 边缘带一点 ALBEDO 的蓝色
}
RIM 的典型应用场景:
| 场景 | RIM | RIM_TINT | 效果 |
|---|---|---|---|
| 科幻护盾 | 2.0 | 0.0 | 强烈白色边缘光 |
| 卡通角色描边光 | 0.5 | 1.0 | 柔和的本色边缘光 |
| 全息投影 | 1.5 | 0.5 | 半透明感的边缘发光 |
| 能量武器 | 3.0 | 0.0 | 刺眼的白边 |
内置 RIM 只给你一个全局的边缘光强度。如果需要在 light() 里精确控制每个光源的 Fresnel 效果、或做 toon 分段的边缘光,就必须自己写 Fresnel 公式。第 20 章会讲到完整的自定义 Fresnel。内置 RIM 适合"快速出效果"的场景——在 fragment() 中一行代码就搞定。
19.10 AO_LIGHT_AFFECT —— 环境光遮蔽的范围控制
AO_LIGHT_AFFECT 解决一个问题:AO 只让环境光变暗,还是同时让直接光照也变暗?
AO默认只影响环境光——角落和缝隙的环境光变暗,但被主光源直接照射到的地方不受影响。AO_LIGHT_AFFECT 扩展了这个行为:
AO_LIGHT_AFFECT = 0.0:AO 只影响环境光,直接光照不打折(默认行为)AO_LIGHT_AFFECT = 1.0:AO 同时影响环境光和直接光照(更暗的暗角效果)
大多数项目中 AO_LIGHT_AFFECT 保持默认的 0.0 即可。只有在需要特别强烈的暗角效果时(如恐怖游戏),才设为更高的值。
下面我们从"认识各种 PBR 贴图"开始,逐步构建一个完整的砖墙 PBR 材质。
19.11 PBR 贴图总览
在下载 PBR 资源时(如 Poly Haven、AmbientCG、Textures.com),你会看到一套命名固定的图片。下表汇总了常见 PBR 贴图及其用途:
| 图片 | 用途 | Godot 对应 | Shader 变量 | 重要性 |
|---|---|---|---|---|
| Diffuse / Color / Albedo | 基础颜色 | Albedo Texture | ALBEDO | ★★★★★ |
| Rough / Roughness | 粗糙度 | Roughness Texture | ROUGHNESS | ★★★★★ |
| AO / Ambient Occlusion | 环境遮蔽 | AO Texture | AO | ★★★★ |
| Normal (GL) / Normal (OpenGL) | 法线贴图 | Normal Texture | NORMAL_MAP | ★★★★★ |
| Normal (DX) / Normal (DirectX) | 法线贴图(DirectX) | 不推荐直接使用 | NORMAL_MAP | ★ |
| Displacement / Height | 位移高度 | Height / Vertex | VERTEX 位移 | ★★ |
| Metal / Metallic | 金属度 | Metallic Texture | METALLIC | ★★★★★ |
| ARM / ORM / Packed | 三合一贴图(AO+Rough+Metal) | ORM Texture | AO + ROUGHNESS + METALLIC | ★★★★ |
关于贴图命名:不同网站的命名稍有差异。Diffuse / Color / Albedo 指同一个东西。ARM / ORM 指同一类打包贴图。下载时一定要看下载页面的说明,确认每个通道对应的数据。
这里有三个地方要特别说明:
Normal (GL) —— Godot 应使用的法线贴图
Godot 使用 OpenGL 法线方向(Vulkan 同理)。如果你从网站下载的是 DirectX 格式的 Normal (DX),需要将绿色通道翻转(normal_map.g = 1.0 - normal_map.g)才能在 Godot 中正确显示。最简单的方法:下载时直接选 OpenGL 版本。
Displacement / Height
Displacement 不是 Normal。法线贴图只是"假装凸起"(通过改变法线方向骗光照系统),而位移贴图真的移动顶点位置(在 vertex() 中沿 NORMAL 方向偏移 VERTEX)。适用于需要真实轮廓变化的效果(如砖墙边缘)。
ARM / ORM —— 现代游戏最常见的打包格式
一张纹理,三个通道,三个 PBR 属性:
| 通道 | 含义 | Shader 变量 |
|---|---|---|
| R | Ambient Occlusion | AO |
| G | Roughness | ROUGHNESS |
| B | Metallic | METALLIC |
不同工具的打包顺序可能不同(如 Unreal 习惯 R=Metal, G=Roughness, B=AO),一定要看下载页面说明。本章假设 R=AO, G=Roughness, B=Metallic 的顺序。
19.12 综合案例一:砖墙 PBR 材质
本案例从零构建一个完整的 PBR 砖墙材质,使用 Poly Haven 或 AmbientCG 下载的标准 PBR 纹理。完整代码见 wall_pbr.gdshader。
场景准备:新建 MeshInstance3D + PlaneMesh 作为墙体,挂载 ShaderMaterial → 新建 Shader(shader_type spatial)。
Step 1:只用 ALBEDO 贴图
最基础的起点:从纹理读取颜色。
shader_type spatial;
uniform sampler2D albedo_tex : source_color;
void fragment() {
ALBEDO = texture(albedo_tex, UV).rgb;
}
把下载的 Diffuse 贴图拖入 albedo_tex,墙体出现了颜色。此时没有 ROUGHNESS(默认 1.0 = 完全粗糙)、没有 METALLIC(默认 0.0 = 非金属)、没有凹凸——只是一个带颜色的平面。
Step 2:加入法线贴图
砖墙的灵魂在凹凸——没有法线贴图,砖缝看起来像画上去的。
shader_type spatial;
uniform sampler2D albedo_tex : source_color;
uniform sampler2D normal_tex : hint_normal;
void fragment() {
ALBEDO = texture(albedo_tex, UV).rgb;
NORMAL_MAP = texture(normal_tex, UV).rgb;
}
现在砖缝有了立体感——光照在砖块的边缘产生明暗变化。hint_normal 确保纹理不被伽马校正扭曲。
Step 3:加入 ARM 贴图 —— 一次控制三个 PBR 属性
shader_type spatial;
uniform sampler2D albedo_tex : source_color;
uniform sampler2D normal_tex : hint_normal;
uniform sampler2D arm_tex : source_color;
void fragment() {
ALBEDO = texture(albedo_tex, UV).rgb;
NORMAL_MAP = texture(normal_tex, UV).rgb;
// ARM 贴图:R=AO, G=Roughness, B=Metallic
vec3 arm = texture(arm_tex, UV).rgb;
AO = arm.r;
ROUGHNESS = arm.g;
METALLIC = arm.b;
}
三次 texture() 采样,六个 PBR 属性的赋值。现在砖墙有了:
- 砖缝处的 AO 暗角(arm.r)
- 砖块表面的粗糙度变化(arm.g)
- 金属度遮蔽(砖块是 0.0 = 非金属,但某些区域如有金属管道则为 1.0)(arm.b)
Step 4:加入位移贴图 —— 让砖块真的凸出来
前两步中,砖缝的凹凸只是"视觉错觉"——从侧面看,墙壁仍然是一个平面。位移贴图改变了这一点。
shader_type spatial;
uniform sampler2D albedo_tex : source_color;
uniform sampler2D normal_tex : hint_normal;
uniform sampler2D height_tex;
uniform sampler2D arm_tex : source_color;
void vertex() {
float height = texture(height_tex, UV).r;
VERTEX += NORMAL * height * 0.2;
}
void fragment() {
ALBEDO = texture(albedo_tex, UV).rgb;
NORMAL_MAP = texture(normal_tex, UV).rgb;
vec3 arm = texture(arm_tex, UV).rgb;
AO = arm.r;
ROUGHNESS = arm.g;
METALLIC = arm.b;
}
位移贴图在 vertex() 中工作——沿法线方向偏移顶点。砖块处的 height 值较高(偏白),被推出;砖缝处的 height 值较低(偏黑),保持不变。现在从侧面也能看到砖块的轮廓。
注意:位移贴图需要模型有足够的顶点密度。如果你用的是 PlaneMesh,需要在编辑器中增大其细分段数才能看到明显效果。
Step 5:最终版本 —— 加入 UV 缩放和可调参数
shader_type spatial;
uniform sampler2D albedo_tex : source_color;
uniform sampler2D normal_tex : hint_normal;
uniform sampler2D height_tex;
uniform sampler2D arm_tex : source_color;
uniform vec2 uv_scale = vec2(1.0);
uniform float displacement_strength : hint_range(0.0, 1.0, 0.1) = 0.2;
void vertex() {
vec2 uv = UV * uv_scale;
float height = texture(height_tex, uv).r;
VERTEX += NORMAL * height * displacement_strength;
}
void fragment() {
vec2 uv = UV * uv_scale;
ALBEDO = texture(albedo_tex, uv).rgb;
NORMAL_MAP = texture(normal_tex, uv).rgb;
vec3 arm = texture(arm_tex, uv).rgb;
AO = arm.r;
ROUGHNESS = arm.g;
METALLIC = arm.b;
}
新增的 uv_scale 和 displacement_strength 让你可以在编辑器中直接调参数——无需改代码就能控制纹理平铺次数和位移强度。
这个 shader 做了什么?
它和 Godot 的 StandardMaterial3D 功能等效——但你现在能看到底层代码。StandardMaterial3D 面板上的 Albedo、Normal、Roughness、Metallic、AO 贴图拖拽框,本质上就是上面这些 texture() 调用的图形界面版本。

19.13 综合案例二:科幻能量护盾
前面章节内容展示了"用贴图驱动材质"的标准流程。但有些效果不需要任何贴图——纯靠代码生成。这就是程序化材质(Procedural Material):零资源依赖,所有色彩和形状都由数学函数计算。
何时用程序化、何时用贴图? 程序化适合抽象或科幻效果(护盾、能量场、全息投影),贴图适合写实场景(砖墙、木材、皮肤)。本章的程序化案例不依赖 Part 4 的任何内容——你可以先学 Part 5 再看 Part 4,也可以反过来。
本案例用纯代码生成一个科幻能量护盾效果——边缘发光、能量波流动、扫描波扩散、噪声扰动和脉冲呼吸。完整代码见 /shield.gdshader。
关键区别:护盾不是不透明表面——它是一个半透明、叠加渲染、双面可见的能量场。因此需要特殊的 render_mode:
blend_add:加法混合——护盾颜色叠加到背景上,亮的更亮cull_disabled:禁用背面剔除——护盾从背面也能看到unshaded:不受场景光照影响——护盾是自发光效果
Step 1:基础边缘光(RIM)
先做一个最简单的护盾——球体边缘发蓝光。
shader_type spatial;
render_mode blend_add, cull_disabled, unshaded;
uniform vec3 color : source_color = vec3(0.2, 0.8, 1.0);
void fragment() {
// 中心化 UV:把 [0,1] 映射到 [-1,1]
vec2 uv = UV * 2.0 - 1.0;
// 到中心的距离
float dist = length(uv);
// 边缘发光:中心暗,边缘亮
float rim = smoothstep(0.3, 0.5, dist);
ALBEDO = color * rim;
}
smoothstep(0.3, 0.5, dist) 在 dist < 0.3 时返回 0(暗),在 dist > 0.5 时返回 1(亮),中间有平滑过渡。效果:球体中心透明、边缘蓝色光环。
Step 2:加入能量波(上下流动的波纹)
shader_type spatial;
render_mode blend_add, cull_disabled, unshaded;
uniform vec3 color : source_color = vec3(0.2, 0.8, 1.0);
uniform float speed = 1.0;
void fragment() {
vec2 uv = UV * 2.0 - 1.0;
float dist = length(uv);
float rim = smoothstep(0.3, 0.5, dist);
// 垂直方向流动的正弦波
float wave = sin((uv.y + TIME * speed) * 10.0);
wave = wave * 0.5 + 0.5; // 映射到 [0, 1]
float energy = rim * wave;
ALBEDO = color * energy;
}
sin((uv.y + TIME) * 10.0) 沿 Y 轴产生 10 个周期的正弦波,TIME 让波形向上流动。wave * 0.5 + 0.5 把 [-1, 1] 的输出映射到 [0, 1]。
Step 3:加入扫描波(从中心向外扩散的环)
void fragment() {
vec2 uv = UV * 2.0 - 1.0;
float dist = length(uv);
float rim = smoothstep(0.3, 0.5, dist);
// 垂直流动波
float wave = sin((uv.y + TIME * speed) * 10.0);
wave = wave * 0.5 + 0.5;
// 环形扫描波:从中心向外扩散
float scan = sin(dist * 20.0 - TIME * 10.0);
scan = smoothstep(0.8, 1.0, scan);
float energy = rim * (wave * 0.5 + scan * 0.5);
ALBEDO = color * energy;
}
扫描波的关键:dist * 20.0 - TIME * 10.0。dist 随距离线性增长 → 环间距均匀;-TIME * 10.0 让环从中心向外扩散。smoothstep(0.8, 1.0, ...) 只保留波峰附近的值,让环看起来更锐利。
Step 4:加入噪声纹理 —— 打破规则的几何感
纯数学函数产生的图案太"规则"——真实的能量场应该有不规则的扰动。加一张噪声纹理。
shader_type spatial;
render_mode blend_add, cull_disabled, unshaded;
uniform vec3 color : source_color = vec3(0.2, 0.8, 1.0);
uniform float speed = 1.0;
uniform sampler2D noise_tex : repeat_enable;
void fragment() {
vec2 uv = UV * 2.0 - 1.0;
float dist = length(uv);
float rim = smoothstep(0.3, 0.5, dist);
float wave = sin((uv.y + TIME * speed) * 10.0);
wave = wave * 0.5 + 0.5;
float scan = sin(dist * 20.0 - TIME * 10.0);
scan = smoothstep(0.8, 1.0, scan);
// 噪声采样(随时间缓慢滚动)
float noise = texture(noise_tex, UV + TIME * 0.3).r;
float energy = rim * (wave * 0.5 + scan * 0.5 + noise * 0.5);
ALBEDO = color * energy;
}
UV + TIME * 0.3 让噪声随时间缓慢位移,产生"能量场在翻滚"的视觉效果。
Step 5:最终版本 —— 加入脉冲呼吸和可调强度
shader_type spatial;
render_mode blend_add, cull_disabled, unshaded;
uniform vec3 color : source_color = vec3(0.2, 0.8, 1.0);
uniform float intensity = 2.0;
uniform float speed = 1.0;
uniform sampler2D noise_tex : repeat_enable;
void fragment() {
vec2 uv = UV * 2.0 - 1.0;
float dist = length(uv);
float rim = smoothstep(0.3, 0.5, dist);
float wave = sin((uv.y + TIME * speed) * 10.0);
wave = wave * 0.5 + 0.5;
float scan = sin(dist * 20.0 - TIME * 10.0);
scan = smoothstep(0.8, 1.0, scan);
float noise = texture(noise_tex, UV + TIME * 0.3).r;
float energy = rim * (wave * 0.5 + scan * 0.5 + noise * 0.5);
// 整体脉冲呼吸
float pulse = 0.5 + 0.4 * sin(TIME * 10.0);
vec3 final_color = color * energy * intensity * pulse;
ALBEDO = final_color;
}
pulse 让整个护盾像呼吸一样明暗交替。intensity 可以整体调节亮度。
进阶玩法:
- 修改
color从蓝色到紫色、橙色——不同能量类型 - 增大
speed让能量流动更快——护盾被激活状态 - 把
intensity设为0.0——护盾关闭 - 把
smoothstep(0.3, 0.5, dist)改为smoothstep(0.6, 0.7, dist)——更细的环
19.14 fragment() 与 light() 的连接点
在进入第 20 章之前,理解 fragment() 输出的 PBR 属性如何传给 light() 至关重要。
fragment() 是"材料装配线"。它在光栅化后的每个像素上运行一次,产出 ALBEDO、ROUGHNESS、METALLIC 等"半成品"。此时还没有任何光照计算发生。
light() 是"后厨灶台"。fragment() 完成后,引擎启动光照阶段:对场景中每一个影响该物体的光源,调用一次 light()。在 light() 内部,你可以读取到 NORMAL、LIGHT、VIEW、ATTENUATION 等光源相关的内置变量,以及 fragment() 中设置好的 ALBEDO、ROUGHNESS、METALLIC、AO 等材质属性。
每个像素的执行顺序:
vertex() ──▶ fragment() ──▶ light() × 光源数 ──▶ 屏幕像素
│ │ │
顶点变换 设置 PBR 属性 读取 PBR 属性
计算 DIFFUSE_LIGHT
计算 SPECULAR_LIGHT
如果没有写 light(),引擎做了什么?
本章所有案例都没有写 light() 函数,但球体、砖墙、护盾依然被正确渲染——光照来自哪里?
答案:Godot 内置了默认的 PBR 光照实现。当你没有写 light() 时,引擎用自己的"默认灶台"接管光照计算。这个默认实现做了以下事情:
- 读取 fragment() 中你设置的 ALBEDO、ROUGHNESS、METALLIC、AO
- 对每个光源,计算标准的 PBR 漫反射 + 镜面反射
- 将环境光、AO、自发光、边缘光(RIM)叠加到最终结果
也就是说,fragment() → 内置 light() 的流程和你手动写 light() 完成的是同一件事,只是前者用引擎的"出厂设置"。第 20 章写自定义 light(),本质上就是替换这个内置实现——你要么接管全部光照计算,要么用 render_mode 微调某个参数的行为。
什么时候该自己写 light()?
| 场景 | 是否写 light() |
|---|---|
| 标准 PBR 材质(木、石、金属、皮肤...) | 不需要,引擎默认光照足够好 |
| 各向异性高光(拉丝金属、头发、CD 光盘) | 需要 |
| 卡通渲染(toon shading)的分段光照 | 需要 |
自定义菲涅尔效果(用 dot(N,V) 手写) |
需要 |
| 透明/半透明材质的光照 | 有时需要 |
核心认识:fragment() 是固定的(描述表面),light() 是可选的(描述光照)。本章的"备菜"步骤在任何 PBR 管线中都一样;第 20 章的"炒菜"则是需要时才定制的环节。绝大部分项目只需 Part 1–4 就能完成 PBR 材质——只有需要非写实风格时才进入 Part 5 和自定义 light()。
在第 20 章你会看到:在 light() 中读取 ALBEDO 做 Lambert 漫反射,读取 ROUGHNESS 控制 Blinn-Phong 高光宽度,读取 METALLIC 决定高光染色。但你现在不需要知道 light() 怎么写——你只需要理解 fragment() 是"备菜",light() 是"炒菜",不写 light() 时引擎用默认灶台帮你炒。
本章小结
本章从概念到实战,覆盖了 PBR 材质的完整知识链:
- 五个核心属性(Part 2):ALBEDO 管本色、ROUGHNESS 管反射模糊度、METALLIC 管金属/非金属、EMISSION 管自发光、AO 管环境遮蔽。每个属性解决一个具体问题,在 fragment() 中独立控制。
- "不写就不编译"机制:未写入的 PBR 变量被引擎优化掉——零开销自由。
- 三个高级效果(Part 3):NORMAL_MAP 用法线贴图骗光照系统产生凹凸、RIM 用内置边缘光快速突出轮廓、AO_LIGHT_AFFECT 控制遮蔽影响范围。
- 贴图驱动 vs 程序化生成(Part 4 & 5):写实场景用 PBR 纹理(砖墙案例),抽象效果用纯代码(护盾案例)。两种工作流互补,覆盖了游戏材质开发的绝大部分需求。
现代 PBR 工作流。在真实游戏项目中,PBR 材质的完整流程是:
Blender / Maya(建模 + 展UV)
↓
Substance Painter / Quixel Mixer(绘制 PBR 贴图)
↓
导出:
Color / Albedo
Normal (OpenGL)
Roughness
Metallic
AO
↓
Godot 引擎
↓
Shader 读取:texture() → ALBEDO / NORMAL_MAP / ROUGHNESS / METALLIC / AO
章末练习
-
ROUGHNESS 实验:在同一场景中放两个 MeshInstance3D + SphereMesh,分别挂载 ROUGHNESS=0.05 和 ROUGHNESS=0.95 的 shader。对比高光、反射、暗面三个方面的差异。参考答案见本书配套 GitHub 仓库。
-
EMISSION 呼吸灯:给 EMISSION 加上时间脉动效果(
EMISSION *= sin(TIME * 2.0) * 0.5 + 0.5)。再尝试用sin(TIME)同时驱动 EMISSION 的亮度变化和颜色偏移(改变 emission_color 的某个通道)。 -
综合材质:模拟一个"正在生锈的金属球"——用
UV.y或噪声函数驱动 METALLIC 和 ROUGHNESS 的区域变化。顶部 METALLIC 低、ROUGHNESS 高(锈蚀),底部 METALLIC 高、ROUGHNESS 低(光滑金属)。观察过渡区的真实感。 -
护盾变体:修改 19.13 节的护盾 shader——将
color改为从橙到紫的渐变(用UV.y做mix()),将rim的smoothstep范围改为(0.6, 0.7)产生更细的边缘环。