跳转至

第 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),在光栅化时,三角形内部像素的法线由三个顶点法线插值得到。所以即使几何面是平的,法线也能平滑过渡——低面模型看起来像高面模型。

Flat Shading(面法线):            Smooth Shading(顶点法线插值):
  三角面内法线不变 → 亮度跳变          顶点法线插值 → 亮度连续渐变

对于 SphereMesh 等内置几何体,Godot 在顶点数据中自动储存了法线。在 shader 中,VERTEX(vertex 函数)和 NORMAL(fragment / light 函数)都是准备好、可直接读取的向量。你不需要手动算法线,但需要理解它们"是什么"和"从哪来"。

下面我们来逐步讲解PBR 的核心属性。


19.2 ALBEDO —— 物体的"本色"

ALBEDO 解决一个问题:这个表面在纯白光照下,呈现什么颜色?

生活例子:一张白纸在日光灯下看起来是白的,在夕阳下看起来是橙的——但纸的"本色"没变。ALBEDO 描述的就是这个"本色"——不包含任何光照信息的固有颜色。

变量介绍:ALBEDO 是一个 vec3,表示表面的固有色。它不包含任何光照信息——暗处的 ALBEDO 和亮处的 ALBEDO 是同一个值。光照系统会拿这个值和光源颜色相乘。

为什么是 vec3 而不是 vec4?3D 物体的透明度通过 render_mode + ALPHAALPHA_SCISSOR 控制,不走 ALBEDO 的 alpha 通道。

最简代码:一个红色的球

shader_type spatial;

void fragment() {
    ALBEDO = vec3(0.8, 0.2, 0.2); // 红色
}

这些代码发生了什么?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 同时控制两件事——反射的模糊程度和反射的亮度。越粗糙的表面,光线被打散到越多方向,单个方向的反射就越暗。

最简代码

void fragment() {
    ALBEDO = vec3(0.6, 0.6, 0.6);
    ROUGHNESS = 0.0; // 镜面
    METALLIC = 0.0;
}

实际用途:用 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 的颜色
吸收光线 部分吸收、部分漫反射 全部反射或全部吸收

最简代码:一个金属球

void fragment() {
    ALBEDO = vec3(1.0, 0.843, 0.0); // 金色
    ROUGHNESS = 0.3;
    METALLIC = 1.0;
}

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;
}

两个实际影响

  1. 零开销自由:不用的属性完全没有性能代价。你不需要"手动关闭"什么——不写就是关。
  2. 清晰可读:看 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) 转换到世界空间:

world_normal = normalize(T × r + B × g + N × b)

其中 rgb 来自法线贴图的 RGB 通道(解压到 [-1,1] 后),分别表示法线在切线空间 X(左右)、Y(上下)、Z(垂直表面)方向的偏转量。

在 Godot 中你不需要手动算 TBN

好消息:Godot 的 spatial shader 在你写入 NORMAL_MAP 时,自动完成 TBN 变换。你只需要把法线贴图采样得到的切线空间法线(解压到 [-1,1] 范围后)赋值给 NORMAL_MAP,引擎负责后续的一切坐标转换。这也意味着 NORMAL_MAP 变量接收的是切线空间的法线,不是世界空间的。

对于需要手动操作 TBN 的高级情况(如视差贴图),Godot 提供了 TANGENTBINORMAL 内置变量。可用 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 同时影响环境光和直接光照(更暗的暗角效果)
void fragment() {
    AO = 0.3;                // 强烈的 AO 遮蔽
    AO_LIGHT_AFFECT = 0.0;   // 只影响环境光,直接光照正常
}

大多数项目中 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_scaledisplacement_strength 让你可以在编辑器中直接调参数——无需改代码就能控制纹理平铺次数和位移强度。

这个 shader 做了什么?

它和 Godot 的 StandardMaterial3D 功能等效——但你现在能看到底层代码。StandardMaterial3D 面板上的 Albedo、Normal、Roughness、Metallic、AO 贴图拖拽框,本质上就是上面这些 texture() 调用的图形界面版本。

lambert


19.13 综合案例二:科幻能量护盾

前面章节内容展示了"用贴图驱动材质"的标准流程。但有些效果不需要任何贴图——纯靠代码生成。这就是程序化材质(Procedural Material):零资源依赖,所有色彩和形状都由数学函数计算。

何时用程序化、何时用贴图? 程序化适合抽象或科幻效果(护盾、能量场、全息投影),贴图适合写实场景(砖墙、木材、皮肤)。本章的程序化案例不依赖 Part 4 的任何内容——你可以先学 Part 5 再看 Part 4,也可以反过来。

本案例用纯代码生成一个科幻能量护盾效果——边缘发光、能量波流动、扫描波扩散、噪声扰动和脉冲呼吸。完整代码见 /shield.gdshader

关键区别:护盾不是不透明表面——它是一个半透明、叠加渲染、双面可见的能量场。因此需要特殊的 render_mode

render_mode blend_add, cull_disabled, unshaded;
  • 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.0dist 随距离线性增长 → 环间距均匀;-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() 时,引擎用自己的"默认灶台"接管光照计算。这个默认实现做了以下事情:

  1. 读取 fragment() 中你设置的 ALBEDO、ROUGHNESS、METALLIC、AO
  2. 对每个光源,计算标准的 PBR 漫反射 + 镜面反射
  3. 将环境光、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 材质的完整知识链:

  1. 五个核心属性(Part 2):ALBEDO 管本色、ROUGHNESS 管反射模糊度、METALLIC 管金属/非金属、EMISSION 管自发光、AO 管环境遮蔽。每个属性解决一个具体问题,在 fragment() 中独立控制。
  2. "不写就不编译"机制:未写入的 PBR 变量被引擎优化掉——零开销自由。
  3. 三个高级效果(Part 3):NORMAL_MAP 用法线贴图骗光照系统产生凹凸、RIM 用内置边缘光快速突出轮廓、AO_LIGHT_AFFECT 控制遮蔽影响范围。
  4. 贴图驱动 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

章末练习

  1. ROUGHNESS 实验:在同一场景中放两个 MeshInstance3D + SphereMesh,分别挂载 ROUGHNESS=0.05 和 ROUGHNESS=0.95 的 shader。对比高光、反射、暗面三个方面的差异。参考答案见本书配套 GitHub 仓库。

  2. EMISSION 呼吸灯:给 EMISSION 加上时间脉动效果(EMISSION *= sin(TIME * 2.0) * 0.5 + 0.5)。再尝试用 sin(TIME) 同时驱动 EMISSION 的亮度变化和颜色偏移(改变 emission_color 的某个通道)。

  3. 综合材质:模拟一个"正在生锈的金属球"——用 UV.y 或噪声函数驱动 METALLIC 和 ROUGHNESS 的区域变化。顶部 METALLIC 低、ROUGHNESS 高(锈蚀),底部 METALLIC 高、ROUGHNESS 低(光滑金属)。观察过渡区的真实感。

  4. 护盾变体:修改 19.13 节的护盾 shader——将 color 改为从橙到紫的渐变(用 UV.ymix()),将 rimsmoothstep 范围改为 (0.6, 0.7) 产生更细的边缘环。