GameTech/URP 渲染实战
CHAPTER 13第 13 章

PBR 材质制作与校准

再好的 PBR Shader,也只能把输入的数据“如实”算出来。贴图勾错一个 sRGB、粗糙度当成光滑度、金属的颜色画暗了,画面立刻就假。本章从贴图的色彩空间讲到取值原则,再用 Rendering Debugger 和自己写的校验 Shader,把材质问题变成看得见的颜色。

本章你会学到
哪些贴图该开 sRGB、哪些必须关,勾错时画面会怎样变化,以及它背后的数学
Mask Map 的通道打包约定、与 URP Lit 和 HDRP 的对应关系,以及压缩对打包的影响
Albedo、金属度、光滑度的取值原则,法线贴图 Y 翻转、未标记 Normal Map 等常见错误
用 URP Rendering Debugger 做材质校验,写一个可对整个场景生效的“PBR 校验”Shader 和导入设置检查工具

13.1 为什么材质需要“校准”

第 9~11 章花了大量篇幅保证 BRDF 是物理合理的:能量守恒、菲涅尔、微表面遮挡、多次散射补偿。但这些保证都有一个前提——输入是合理的。BRDF 假设基础色是“表面在各个波长上反射多少比例的光”,那它就必须在 0 到 1 之间,而且现实中几乎没有材质会反射 100% 或 0% 的光;BRDF 假设金属度要么是 0 要么是 1,因为现实中一个微观点不是金属就是非金属;BRDF 假设法线贴图存的是切线空间的单位向量,坐标轴方向和引擎约定一致。任何一个前提被打破,Shader 都会忠实地算出一个“错误但看起来能接受”的结果,这比直接报错更危险。

“校准”就是让材质数据回到这些前提之内。它包含三个层次:数据层(贴图的导入设置、通道含义、色彩空间对不对)、取值层(数值是否在合理范围)、观察层(在什么光照环境下判断材质,判断时能否排除光照的干扰)。本章按这个顺序展开,最后给出三件工具:URP 自带的 Rendering Debugger、我们自己写的 PBR 校验 Shader、一个批量检查导入设置的编辑器脚本。

13.2 色彩空间与贴图通道

两种贴图:颜色与数据

工程设为 Linear 色彩空间后,所有光照计算都在线性空间进行。但贴图文件大多以 8 位存储,人眼对暗部更敏感,如果线性地平均分配 256 个台阶,暗部会出现明显的色带。所以颜色图像(照片、手绘的颜色贴图)通常用 sRGB 编码存储:暗部分配更多台阶。读取时必须先解码回线性,Shader 才能正确计算。sRGB 的解码函数(摘自 IEC 61966-2-1 标准,也是 core 包 Color.hlsl 中 SRGBToLinear 的实现)是:

Clinear={Csrgb12.92,Csrgb≤0.04045(Csrgb+0.0551.055)2.4,其他
贴图里存的值(sRGB 编码) 解码后的线性值 128 → 0.216 sRGB 解码曲线 不解码(当成线性) 0255 导入设置的规则 开 sRGB(颜色) Albedo / Base Color、自发光、 Specular 工作流的高光颜色 关 sRGB(数据) 金属度、光滑/粗糙度、AO、高度、 Mask、Flowmap、噪声;法线另设类型
图 13-1sRGB 解码曲线与导入规则。中间灰 128 解码后只有约 0.216;数据贴图如果也被这样“解码”,数值就会被系统性压低。

判断一张贴图该不该开 sRGB,只问一个问题:它存的是“给人看的颜色”,还是“给 Shader 用的数字”?颜色贴图开 sRGB;金属度、光滑度、AO、高度、Mask 这类数据贴图关 sRGB——它们的 0.5 就意味着 0.5,不应该被一条视觉曲线扭曲。法线贴图是特殊的数据贴图,Texture Type 要设成 Normal Map,Unity 会自动按线性处理,并换成专门的压缩格式。

在 Unity 中,这个开关就是 Texture Import Settings 里的 sRGB (Color Texture)。勾上时,GPU 会用 sRGB 格式(例如 BC7_SRGB、R8G8B8A8_SRGB)创建贴图,硬件在采样和过滤时自动解码,Shader 里拿到的已经是线性值,不需要也不应该再写任何 pow。本书的配套工程用一个 AssetPostprocessor(_Core/Editor/CookbookTextureImporter.cs)按文件名后缀自动设置:_normal 设为 Normal Map,_mask、_height、noise_ 关闭 sRGB。项目里建议也这样做,把规则写成代码比靠人记可靠得多。

勾错了会怎样

图 13-2同一套贴图,三种导入设置(URP Lit)。左:正确;中:Albedo 关闭了 sRGB(被当作线性数据),颜色发灰发白;右:Mask 开启了 sRGB(被当作颜色解码),漆面光滑度从约 0.55 掉到约 0.26,高光消失,砖块也更“干”。

中图的错误最容易被发现:颜色贴图被当成线性数据,相当于少做了一次解码,中间调被整体抬高,画面像蒙了一层白雾。右图的错误更隐蔽:Mask 被当成颜色解码,金属度在 0 和 1 两端不受影响(解码曲线经过这两个点),但所有中间值——光滑度、AO、金属过渡边缘——都被压暗。漆面光滑度 0.55 解码后只剩 0.26 左右,高光几乎消失。美术如果在这种状态下“调好”材质,往往会把光滑度贴图画得过亮来补偿,问题从此被掩盖进了资产里。

注意色彩空间设置为 Gamma 的项目里,sRGB 勾选框的行为不同(贴图不会被解码,光照在非线性空间计算),本书所有内容都基于 Linear。新项目请始终使用 Linear;从 Gamma 迁移到 Linear 时,原来的材质颜色和灯光强度都需要重新校准。

13.3 通道打包:Mask Map

金属度、AO、光滑度都是单通道数据。分别存成三张灰度图,意味着三次采样、三个采样器槽位、三份 mip 链的内存和带宽。把它们打包进一张 RGBA 贴图的不同通道,一次采样就能拿全,这就是 Mask Map(也叫 ORM、MRAO 等,各家叫法不同)。

同一类数据,在不同 Shader 里的通道约定 通道本书 / MyPBRURP LitHDRP Lit Mask Map R G B A 金属度AO(未用)光滑度 金属度 ← Metallic 槽AO ← Occlusion 槽(未用)光滑度 ← Metallic 槽 金属度AO细节遮罩光滑度
图 13-3常见的通道约定。本书的配套贴图与 HDRP 的 R/G/A 位置一致;URP Lit 虽然把它们放在两个贴图槽里,但读的通道恰好相同,所以同一张 Mask 可以同时拖进 Metallic 与 Occlusion 两个槽。

URP Lit 的 SampleMetallicSpecGloss 读 _MetallicGlossMap 的 R 作为金属度、A 作为光滑度(再乘 Smoothness 滑条);SampleOcclusion 读 _OcclusionMap 的 G 通道(摘自 URP 14 Shaders/LitInput.hlsl)。所以一张 R 金属、G AO、A 光滑的 Mask 可以同时放进两个槽,URP Lit 会各取所需——第 11 章的对比图就是这样配置的。代价是同一张贴图被采样两次,如果追求极致,可以像 MyPBR 那样写自己的 Shader,一次采样拿全。

打包时有三个细节值得注意:

  • 光滑度还是粗糙度。Unity 系的 Shader(URP、HDRP、Built-in Standard)都用光滑度;Unreal、glTF 和大部分 DCC 工具导出的是粗糙度。两者互为 1−x,混用的后果见图 13-5。
  • 压缩格式与通道串扰。BC1/BC3(DXT1/DXT5)的 RGB 三个通道共享块内的颜色端点,三个毫不相关的灰度图挤进 RGB 会互相干扰,出现块状噪点;BC3 的 A 通道是独立压缩的,质量最好,所以光滑度这种对高光影响最大的数据通常放在 A。桌面平台的 BC7、移动平台的 ASTC 质量更高,但同样建议把最重要的数据放进独立性最好的通道,并在打包后检查压缩结果。
  • mip 与过滤。AO、金属度在 mip 中被平均是合理的;光滑度在远处被平均后,会丢失“法线细节被平均掉”带来的额外粗糙——远处的物体反而显得更光滑、高光闪烁。这个问题叫镜面走样,处理方法(Toksvig、按法线方差调整粗糙度等)超出本章范围,这里只提醒:法线贴图和光滑度贴图是耦合的。
图 13-4程序生成的刷漆金属套图(见案例 13.1),用 ChannelView Shader 逐通道查看。从左到右:Albedo(RGB);Mask.R 金属度,白色是漆皮剥落露出的金属;Mask.G AO,剥落边缘积灰更暗;Mask.A 光滑度,漆面中等、金属较高、边缘更粗糙。

逐通道查看贴图是检查打包是否正确的第一步。ChannelView 是一个很小的无光照 Shader,它保证屏幕上的像素值等于贴图里存的数值:对线性贴图,采样值就是存储值,输出前做一次 SRGBToLinear 抵消相机目标的 sRGB 编码;对 sRGB 贴图,硬件已经解码,直接输出即可。

Assets/Cookbook/Ch13/ChannelView.shader
// 单独查看贴图的某个通道(不受光照影响)。
// 目标:屏幕上的像素值 = 贴图文件里存的数值。
//   线性贴图(Mask、法线):采样值就是存的值,输出前做 SRGBToLinear,抵消相机目标的 sRGB 编码;
//   sRGB 贴图(Albedo):采样时硬件已解码成线性,直接输出,目标编码回去正好还原。
Shader "Cookbook/Ch13/ChannelView"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        [Enum(R,0,G,1,B,2,A,3,RGB,4)] _Channel ("Channel", Float) = 4
        [Toggle] _LinearData ("Texture Is Linear Data", Float) = 1
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }
        Pass
        {
            Tags { "LightMode"="UniversalForward" }
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Color.hlsl"

            CBUFFER_START(UnityPerMaterial)
                float4 _MainTex_ST;
                float  _Channel;
                float  _LinearData;
            CBUFFER_END
            TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex);

            struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; };
            struct Varyings   { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; };
            Varyings vert(Attributes v) { Varyings o; o.positionCS = TransformObjectToHClip(v.positionOS.xyz); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; }

            half4 frag(Varyings i) : SV_Target
            {
                // 注意:采样到的值已经过“导入设置决定的”解码——sRGB 贴图会被转成线性
                half4 t = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
                int ch = (int)_Channel;
                half3 c = ch == 4 ? t.rgb : t[min(ch, 3)].xxx;
                return half4(_LinearData > 0.5 ? SRGBToLinear(c) : c, 1);
            }
            ENDHLSL
        }
    }
}
// 单独查看贴图的某个通道(不受光照影响)。(GLSL 版,对应 Ch13/ChannelView.shader)
// 目标:屏幕上的像素值 = 贴图文件里存的数值。
//   线性贴图(Mask、法线):采样值就是存的值,输出前做 SRGBToLinear,抵消相机目标的 sRGB 编码;
//   sRGB 贴图(Albedo):采样时硬件已解码成线性,直接输出,目标编码回去正好还原。
Shader "Cookbook/Ch13/ChannelView"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }
        Pass
        {
            Tags { "LightMode"="UniversalForward" }
            GLSLPROGRAM
            uniform vec4  _MainTex_ST;
            uniform float _Channel;
            uniform float _LinearData;
            uniform sampler2D _MainTex;

            VARYING vec2 uv;
            #ifdef VERTEX
            void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); uv = TransformTex(texcoord0.xy, _MainTex_ST); }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                // 注意:采样到的值已经过“导入设置决定的”解码——sRGB 贴图会被转成线性
                vec4 t = texture(_MainTex, uv);
                int ch = int(_Channel);
                vec3 c = ch == 4 ? t.rgb : vec3(t[min(ch, 3)]);
                fragColor = vec4(_LinearData > 0.5 ? SRGBToLinear(c) : c, 1.0);
            }
            #endif
            ENDGLSL
        }
    }
}
图 13-5把粗糙度当光滑度用。左:正确的砖墙 Mask(砖块光滑度约 0.25~0.4,灰缝约 0.1);右:A 通道被取反,相当于把一张粗糙度贴图直接塞进了光滑度通道,砖墙像被雨淋湿一样泛光,原本更粗糙的灰缝反而最亮。

这类错误有一个可靠的识别特征:本应最粗糙的地方(缝隙、边缘磨损、灰尘)反而最亮。修正方法有三种:在 DCC 里重新导出光滑度;在 Unity 2022 的导入设置里用 Swizzle 把对应通道设为 1 − A(2022.x 起导入器提供 Swizzle 选项,可用 TextureImporterSwizzle.OneMinusA 等值);或者在打包脚本里统一取反。不推荐在 Shader 里加“是否取反”的开关,那会让每个材质多一个可能被设错的参数。

案例 13.1程序化刷漆金属:一套参数生成三张贴图进阶单独打开 ↗

目标:用 C# 从一组参数生成 Albedo、Mask、Normal 三张贴图,体会“各通道从同一份结构派生、彼此对得上”的制作思路,并观察每张贴图加入后的变化。

手绘或扫描材质时最常见的问题之一,是各张贴图之间对不上:漆皮剥落的地方,Albedo 画了金属色,金属度却没画成 1;划痕在法线里有,在光滑度里没有。程序化生成天然避免了这个问题——所有通道都来自同一张“剥落程度”的噪声。

Assets/Cookbook/Ch13/PaintedMetalGenerator.cs(节选:settings)
[System.Serializable]
public struct Settings
{
    public Color paintColor;      // 漆面颜色(sRGB,美术直觉)
    public Color metalColor;      // 裸金属颜色(sRGB)——金属的 Albedo 就是它的 F0,要亮
    public float wear;            // 剥落程度 0~1
    public float edgeSoftness;    // 剥落边缘的过渡宽度
    public float paintSmoothness; // 漆面光滑度
    public float metalSmoothness; // 裸金属光滑度
    public float paintThickness;  // 漆皮厚度(只影响法线强度)
    public int   seed;
    public int   size;

    public static Settings Default => new Settings
    {
        paintColor = new Color(0.10f, 0.36f, 0.50f), metalColor = new Color(0.78f, 0.78f, 0.80f),
        wear = 0.4f, edgeSoftness = 0.035f, paintSmoothness = 0.55f, metalSmoothness = 0.78f,
        paintThickness = 4f, seed = 7, size = 256
    };
}
Assets/Cookbook/Ch13/PaintedMetalGenerator.cs(节选:generate)
public static Result Generate(Settings st)
{
    int N = st.size;
    var chip = new float[N * N];
    var grime = new float[N * N];
    var height = new float[N * N];
    for (int y = 0; y < N; y++)
        for (int x = 0; x < N; x++)
        {
            float u = (float)x / N, v = (float)y / N;
            float n = Fbm(u, v, 6, 5, st.seed);           // 剥落形状
            float g = Fbm(u, v, 16, 4, st.seed + 101);     // 细碎的脏污/磨损
            // wear 越大,阈值越低,露出的金属越多
            float t = 1f - st.wear;
            float c = Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(t - st.edgeSoftness, t + st.edgeSoftness, n));
            chip[y * N + x] = c;
            grime[y * N + x] = g;
            height[y * N + x] = 1f - c;                    // 漆皮高、裸金属低
        }

    var albedo = new Color[N * N];
    var mask = new Color[N * N];
    var normal = new Color[N * N];
    for (int y = 0; y < N; y++)
        for (int x = 0; x < N; x++)
        {
            int i = y * N + x;
            float c = chip[i], g = grime[i];
            float edge = 4f * c * (1f - c);                // 剥落边缘带:0 → 1 → 0
            // Albedo:漆面按脏污轻微明暗变化;金属保持明亮(不要把金属画暗)
            Color paint = st.paintColor * (0.88f + 0.24f * g);
            Color metal = st.metalColor * (0.92f + 0.12f * g);
            Color a = Color.Lerp(paint, metal, c);
            a = Color.Lerp(a, a * 0.8f, edge * 0.5f);      // 边缘积灰略暗
            albedo[i] = new Color(a.r, a.g, a.b, 1);

            // Mask:R 金属度(剥落处 = 1);G AO(边缘/凹处偏暗);A 光滑度
            float metallic = c;
            float ao = Mathf.Clamp01(1f - 0.35f * edge - 0.12f * (1f - g));
            float smooth = Mathf.Lerp(st.paintSmoothness * (0.85f + 0.3f * g), st.metalSmoothness * (0.9f + 0.1f * g), c);
            smooth *= 1f - 0.4f * edge;                    // 边缘更粗糙
            mask[i] = new Color(metallic, ao, 0, Mathf.Clamp01(smooth));

            // 法线:对高度做中心差分(可平铺,取模回绕)
            float hL = height[y * N + (x + N - 1) % N], hR = height[y * N + (x + 1) % N];
            float hD = height[((y + N - 1) % N) * N + x], hU = height[((y + 1) % N) * N + x];
            var nrm = new Vector3(-(hR - hL) * 0.5f * st.paintThickness, -(hU - hD) * 0.5f * st.paintThickness, 1f).normalized;
            normal[i] = new Color(nrm.x * 0.5f + 0.5f, nrm.y * 0.5f + 0.5f, nrm.z * 0.5f + 0.5f, 1);
        }

    return new Result
    {
        albedo = Make(albedo, N, false, "PM_Albedo"),   // 颜色贴图:sRGB
        mask   = Make(mask, N, true, "PM_Mask"),        // 数据贴图:线性
        normal = Make(normal, N, true, "PM_Normal"),    // 法线:线性(不能做 sRGB 解码)
    };
}

生成流程可以概括为“一张结构图,派生所有通道”:

  1. 结构:一张低频 fBm 噪声 n 决定漆皮在哪里剥落。wear 参数控制阈值,越大露出的金属越多;smoothstep 给出一条有一定宽度的过渡带 c(0 = 漆,1 = 金属)。另一张高频噪声 g 提供细碎的脏污变化。
  2. Albedo:漆色与金属色按 c 混合。金属色故意保持明亮——金属的 Albedo 就是它的 F0,现实中常见金属的反射率都很高(13.4 节)。剥落边缘的过渡带略微变暗,模拟积灰。
  3. Mask:R 直接等于 c,过渡带上会有 0~1 之间的值,这是允许的(像素覆盖了一部分漆、一部分金属);G 在边缘带降低,模拟缝隙中的遮蔽;A 在漆和金属之间插值,并在边缘带降低,因为剥落边缘粗糙。
  4. Normal:漆皮有厚度,高度 = 1 − c;对高度做中心差分得到切线空间法线。注意 Unity 的切线空间 +Y 对应贴图的 +V 方向(OpenGL 约定),而 SetPixels 的第 0 行是贴图底部,所以向上的差分就是 +Y,不需要翻转。
  5. 色彩空间:Albedo 创建为 sRGB 贴图(linear: false),Mask 和 Normal 创建为线性贴图。生成时我们给的颜色本身就是 sRGB 值(美术在取色器里看到的数值),与导入设置保持一致。
图 13-6逐步加入贴图(URP Lit)。从左到右:只有 Albedo(金属度 0、光滑度 0.5,剥落处像白色塑料);+ Mask(剥落处变成金属并反射环境);+ Normal(漆皮边缘出现厚度);换一组参数(wear 0.62,红色漆,不同种子)重新生成。

第一格与第二格的差异最能说明金属度的作用:同样一块浅灰色区域,金属度为 0 时它是“浅灰色塑料”,漫反射占主导;金属度为 1 时它没有漫反射,颜色完全来自对环境的反射。第四格展示了程序化的价值:改两个参数就得到一套新的、各通道自洽的贴图。实际项目中,这类生成通常在 Substance Designer、Houdini 或自研工具中完成,原理相同。

13.4 取值原则:Albedo、金属度与光滑度

物理上,材质参数应当来自测量。但本书不打算给你一张“XX 材料的 Albedo 是多少”的表——测量值依赖样品、光谱、测量方式,抄来的数字不一定适合你的项目,而且很容易被误用。下面只讲原则和业内公认的范围,以及这些范围的出处。

Albedo(基础色)

  • 不要用纯黑和纯白。现实中几乎没有表面反射 0% 或 100% 的光:最黑的常见材料(木炭、黑色橡胶)也会反射几个百分点,新雪是最白的常见材料之一,也不到 100%。纯黑的漫反射会让物体在任何光照下都“吸光”,阴影中失去所有细节;纯白会让漫反射吃掉几乎全部入射光,在强光下大面积过曝。
  • 业内常引用的范围。Substance(Allegorithmic)发布的 PBR 指南建议:电介质 Albedo 的暗部不低于 sRGB 约 30~50,亮部不高于 sRGB 约 240。URP 的 Rendering Debugger 默认使用的线性亮度范围是 0.01~0.90(摘自 URP 14 DebugDisplaySettingsMaterial.cs 的默认值),比前者宽松。两个范围都是“提醒你检查”的阈值,而不是硬性规定。
  • 金属的 Albedo 就是 F0,应当明亮。同一份 PBR 指南给出的金属反射率范围对应 sRGB 约 180~255(不同金属颜色不同,例如金偏黄、铜偏红)。把金属画暗,得到的不是“暗色金属”,而是像石墨或黑漆的东西。
  • Albedo 里不能画光照:不能画阴影、AO、高光、环境色。AO 放进 AO 通道,高光由 BRDF 产生,阴影由灯光产生。扫描或照片得来的贴图尤其要去光照(De-lighting)。
0255(sRGB) 过暗 <30~50 常见电介质 金属 ≈180~255 过亮 >240
图 13-7Albedo 亮度的经验范围(sRGB 0~255,横轴按 sRGB 值线性排布)。数值引自 Substance 的 PBR 指南,作为检查阈值使用。
图 13-88 个电介质灰球,Albedo 依次为 sRGB 0、15、45、80、128、190、235、255。左:URP Lit 着色;右:用本章的 PBRValidate Shader 查看——红色条纹为过暗(低于 30),蓝色条纹为过亮(高于 240)。

注意左图中最左边两个球:它们的差异几乎只剩高光,漫反射已经“死”了;最右边的球在主光下大面积发白。人眼对此并不敏感——单看一颗球都“还行”,但当它们出现在同一个场景里,被不同的光照照亮时,过暗和过亮的材质会明显不协调。

金属度

金属度是一个“分类”参数,不是“程度”参数。一个微观点要么是金属,要么不是,所以金属度贴图应当基本是黑白的。0~1 之间的值只应该出现在:抗锯齿的过渡边缘、漆皮剥落的过渡带(一个像素覆盖了两种材质)、金属表面覆盖了一层薄薄的灰尘或氧化物。大面积 0.5 的“半金属”在物理上没有对应物,看起来通常像蒙了一层灰的塑料。

图 13-9金属度的常见错误(URP Lit,光滑度均为 0.75)。从左到右:正确的金色金属(亮 Albedo,金属度 1);金属的 Albedo 画得很暗(像黑色釉面而不是金属);大面积金属度 0.5(发灰、发“塑料”);金属忘了开金属度(变成黄色塑料,高光是白色的);红色塑料被设成金属(变成红色镜面,高光也是红色的)。

第四个和第五个球给出了区分金属与电介质最可靠的视觉线索:高光的颜色。电介质的 F0 几乎是无色的,所以高光是光源的颜色(白色);金属的 F0 就是它的 Albedo,高光带着金属自己的颜色。如果你在场景里看到一个“白色高光的金子”或“红色高光的塑料”,金属度一定设错了。

光滑度

光滑度没有可以直接测量的“标准值”——它描述的是微观表面的统计粗糙程度,同一种材料抛光与否差别巨大。可以遵循的原则是:

  • 光滑度承载的是材质的表面状态:磨损、灰尘、指纹、水渍、油污都主要体现在光滑度上,它往往是最能表现“故事”的通道。
  • 避免整张贴图用同一个值;即使是“均匀”的材质,细微的光滑度变化也能打破 CG 感。
  • 极高的光滑度(接近 1)只用于真正抛光的表面:镜子、抛光金属、清漆、水面。大多数日常物体处在中间区域。
  • 光滑度在不同光照下的观感差异很大(见 13.6 节),应在多种光照下检查。
电介质的 F0金属度工作流把所有电介质的 F0 固定为 0.04(对应折射率约 1.5),这对大多数非金属足够准确。需要表现折射率明显不同的电介质(宝石、水的高光差异)时,可以用 Specular 工作流直接指定 F0,或者像 URP Lit 一样提供 Specular 工作流开关。

13.5 法线贴图的常见错误

法线贴图出错时,画面往往“看起来有凹凸”,只是凹凸的方向、强度或光照反应不对,很难一眼发现。下面四种是最常见的情况。

OpenGL 约定(Unity,Y+) +X(R) +Y(G)向上 凸起的上沿:G 偏亮 DirectX 约定(Y−) +X(R) +Y(G)向下 凸起的上沿:G 偏暗 G 取反⇄
图 13-10切线空间法线贴图的两种约定,差别只在 G 通道的符号。Unity 使用 OpenGL 约定;Unreal 等引擎使用 DirectX 约定,很多烘焙与绘制工具两种都能导出。
图 13-11法线贴图的四种状态(光从正上方贴着墙面照下)。从左到右:正确(砖块上沿受光、下沿背光,砖块凸出);G 通道翻转(上沿暗、下沿亮,砖块看起来凹进去);没有标记为 Normal Map(被当成 sRGB 颜色解码,整体法线偏向左下,墙面发暗且受光方向错乱);强度拉到 3.5(细碎的噪声被放大,像一层粗糙的砂纸)。

Y 翻转

用 DirectX 约定导出的法线贴图放进 Unity,凸起会变成凹陷——前提是光从上下方向照射;如果光从左右照来,翻转 G 几乎看不出来,这就是这个错误常常逃过检查的原因。检查方法:用一盏从上方照射的灯,看凸起物的上沿是否受光。修正方法:在导出工具中选择 OpenGL;或在 Unity 导入设置的 Swizzle 中把 G 设为 1 − G;或在批处理脚本中翻转。

没有标记为 Normal Map

法线贴图被当作默认类型导入时,会默认开启 sRGB。存储值 128(代表分量 0)被解码成 0.216,再经过 ×2 − 1 变成 −0.57——整张图的法线都向 −X、−Y 方向倾斜。同时,贴图会被按颜色贴图压缩(例如 BC1),而不是法线专用的 BC5/DXT5nm 布局。Unity 在材质 Inspector 中检测到这种情况时会提示 “This texture is not marked as a normal map” 并给出 Fix Now 按钮,但通过脚本批量创建的材质往往没有人看到这个提示,13.7 节的检查工具专门检查这一项。

强度与 mip

法线强度(Bump Scale)放大的是切线平面内的分量。拉得过高时,原本细微的噪声会变成显眼的颗粒,而且远处的 mip 被平均后法线长度变短,高光闪烁更严重。合理的做法是在烘焙或绘制阶段就把强度做对,Shader 中的强度参数用于微调。

切线不一致

法线贴图是相对于“烘焙时使用的切线空间”记录的。如果模型在 Unity 中计算的切线和烘焙工具用的不同,即使贴图本身没错,接缝处也会出现明暗断裂。业界的通行做法是两边都使用 MikkTSpace 切线空间:Unity 模型导入设置中 Tangents 选 Calculate Mikktspace(默认),烘焙工具也选 MikkTSpace。镜像 UV 时 tangent.w 为 −1,Shader 中必须用它重建副切线(第 7、11 章的代码都这样做了)。

13.6 光照环境对材质判断的影响

同一个材质在不同光照下的观感可以差得很远。美术在一个暖色、低角度、对比强烈的场景里调出的材质,放到中性光照下往往偏色、偏亮或偏暗。原因很简单:我们看到的是“材质 × 光照”,而光照的颜色、方向、强度和环境反射都在改变最终颜色。

图 13-12同一组材质(刷漆金属球、中性灰参照球、砖块)在三种光照下。左:中性天空 + 白色主光;中:低角度暖色夕阳 + 偏蓝的暗环境光;右:黑暗房间里一盏偏冷的点光源,没有环境光和反射。

中图中,灰色参照球变成了橙色,砖块的红色被加强;右图中,金属球的剥落处变成黑色——没有环境可以反射,金属就只剩高光,这与案例 11.2 中看到的现象相同。如果只在中图或右图里调材质,很容易把灰球的 Albedo 调成偏蓝去“纠正”它,或者把金属画亮去“补”它。正确的做法:

  • 使用中性的看板环境(Look-dev):中性灰或均匀的天空、白色主光,放一个中性灰电介质球(例如 sRGB 118 左右,对应约 18% 反射率的“灰卡”)和一个铬球作参照。HDRP 有专门的 Look Dev 工具,URP 项目可以自己搭一个这样的场景。
  • 在多种光照下验证:中性光照下调好后,再放进项目中的典型光照(白天、夜晚、室内)检查。
  • 注意后处理:Tonemapping、Color Grading、曝光会改变最终颜色。调材质时要么关闭后处理,要么使用项目最终的后处理设置,不要在两者之间来回切换判断。
  • 用工具而不是眼睛判断数值:取色器在屏幕上取到的是“材质 × 光照 × 后处理”的结果,不是材质值。要检查材质值本身,用下一节的 Rendering Debugger 或校验 Shader。

13.7 Rendering Debugger 的材质校验

URP 自带的 Rendering Debugger(Window › Analysis › Rendering Debugger)在 Material 面板中提供三类工具:

选项作用典型用途
Material Override直接输出某个材质属性:Albedo、Specular、Alpha、Smoothness、Ambient Occlusion、Emission、Normal(世界/切线空间)、Metallic,以及 Lighting Complexity不受光照影响地检查贴图内容和通道打包
Vertex Attribute输出 UV0~UV3、顶点色、切线、法线检查模型数据、切线是否正确
Material ValidationAlbedo:亮度超出范围的像素标红(过暗)或标蓝(过亮),可选预设与色相/饱和度容差;Metallic:金属度低于下限标红、高于上限标蓝批量找出取值不合理的材质

Albedo 校验的逻辑很直接(摘自 URP 14 ShaderLibrary/Debug/DebuggingCommon.hlsl):

half luminance = Luminance(albedo);
if (luminance < _DebugValidateAlbedoMinLuminance)
    color = _DebugValidateBelowMinThresholdColor;      // 红
else if (luminance > _DebugValidateAlbedoMaxLuminance)
    color = _DebugValidateAboveMaxThresholdColor;      // 蓝
else
{   // 在范围内时,再按所选预设的色相与饱和度容差检查(默认预设容差为 1,等于不检查)
    ...
}

其中 Luminance 是线性 RGB 的 Rec.709 亮度 0.2126R+0.7152G+0.0722B。Validation Preset 下拉框中内置了一组预设,每个预设给出一个参考颜色和亮度范围,源码注释说明颜色取自 BabelColor 整理的 ColorChecker 色卡数据。下表摘录其中几项(数值均为 URP 14 源码中的设定):

预设线性亮度范围预设线性亮度范围
Default Luminance0.01~0.90Old Concrete0.17~0.30
Black Acrylic Paint0.03~0.07Dry Sand0.20~0.45
Dark Soil0.05~0.14New Concrete0.32~0.55
Green Grass0.16~0.26White Acrylic Paint0.75~0.85
Red Clay Tile0.23~0.33Fresh Snow0.85~0.95

这些预设的用途是“针对某一类材质做更严格的检查”:例如场景里都是混凝土,选 Old Concrete 预设,所有不在这个范围、或色相饱和度偏离参考色的像素都会被标出。默认预设只检查亮度,不检查色相。

图 13-13Rendering Debugger 的材质校验。球从左到右:过暗电介质、正常电介质、纯白电介质、金属度 0.5、暗色金属、正常金属。左:正常着色;中:Albedo 校验(默认范围 0.01~0.90),过暗标红、过亮标蓝;右:Metallic 校验(默认范围 0~0.9),所有金属度为 1 的物体都被标蓝。

这张图揭示了 Rendering Debugger 的两个特点。第一,Albedo 校验不区分金属与电介质——明亮的金属 Albedo 完全合理,但同样可能被标成蓝色(本例的银色金属恰好在 0.9 以下)。第二,Metallic 校验只是一个“范围检查”:默认上限 0.9 意味着纯金属会被标蓝,而真正有问题的 0.5 半金属反而显示为灰色。它适合“这个场景里不该有金属”这类检查,不擅长找“金属度灰区”。要做更贴合 PBR 原则的检查,需要自己写——这就是下一个案例。

提示Rendering Debugger 只对实现了 DEBUG_DISPLAY 分支的 Shader 有效:URP 的 Lit、Simple Lit、Complex Lit、Shader Graph 生成的 Shader 都支持;自写 Shader 需要像第 11 章 MyPBR 那样,声明 #pragma multi_compile_fragment _ DEBUG_DISPLAY,把表面参数填进 SurfaceData,调用 CanDebugOverrideOutputColor。否则在调试模式下这些物体会照常着色,很容易被误以为“没有问题”。

案例 13.2写一个“PBR 校验”Shader进阶单独打开 ↗

目标:写一个读取 URP Lit 材质属性的校验 Shader,按 PBR 原则把超出范围的像素用醒目的斜条纹标出来,并能一键作用于整个场景。

规则按材质类型分开判断,比 Rendering Debugger 更贴近 13.4 节的原则:

条件标记含义
0.1 < 金属度 < 0.9黄色条纹金属度灰区,只应出现在过渡边缘
金属度 ≥ 0.9 且 Albedo 最大分量 < sRGB 180品红条纹金属的 F0 过暗
电介质且亮度 < sRGB 30红色条纹电介质过暗
电介质且亮度 > sRGB 240蓝色条纹电介质过亮
Assets/Cookbook/Ch13/PBRValidate.shader(节选:thresholds)
// 阈值用 sRGB 0~1 表示(美术在取色器里看到的数值 / 255)。
// 全局变量,由 C# 用 Shader.SetGlobalVector 设置;w <= 0 时使用下面的默认值。
float4 _PBRValidateRange;   // x 电介质最暗  y 电介质最亮  z 金属最暗
#define DEFAULT_RANGE float3(30.0 / 255.0, 240.0 / 255.0, 180.0 / 255.0)
#define METAL_GRAY_LOW  0.1
#define METAL_GRAY_HIGH 0.9
// 阈值用 sRGB 0~1 表示(美术在取色器里看到的数值 / 255)。
// 全局变量,由 C# 用 Shader.SetGlobalVector 设置;w <= 0 时使用下面的默认值。
uniform vec4 _PBRValidateRange;   // x 电介质最暗  y 电介质最亮  z 金属最暗
#define DEFAULT_RANGE vec3(30.0 / 255.0, 240.0 / 255.0, 180.0 / 255.0)
#define METAL_GRAY_LOW  0.1
#define METAL_GRAY_HIGH 0.9
Assets/Cookbook/Ch13/PBRValidate.shader(节选:validate)
half4 frag(Varyings i) : SV_Target
{
    half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv).rgb * _BaseColor.rgb;   // 线性
#if defined(_METALLICSPECGLOSSMAP)
    half metallic = SAMPLE_TEXTURE2D(_MetallicGlossMap, sampler_MetallicGlossMap, i.uv).r;
#else
    half metallic = _Metallic;
#endif

    float3 range = _PBRValidateRange.w > 0 ? _PBRValidateRange.xyz : DEFAULT_RANGE;
    // 回到 sRGB 再比较,和美术在 Photoshop/Substance 里看到的数值一致
    half lumS = LinearToSRGB(Luminance(albedo).xxx).x;
    half maxS = LinearToSRGB(max(albedo.r, max(albedo.g, albedo.b)).xxx).x;

    half3 flag = 0; half bad = 0;
    if (metallic > METAL_GRAY_LOW && metallic < METAL_GRAY_HIGH) { flag = half3(1, 0.85, 0); bad = 1; }   // 黄
    else if (metallic >= METAL_GRAY_HIGH)
    {
        if (maxS < range.z) { flag = half3(1, 0, 1); bad = 1; }                                          // 品红
    }
    else
    {
        if (lumS < range.x)      { flag = half3(1, 0.1, 0.1); bad = 1; }                                // 红
        else if (lumS > range.y) { flag = half3(0.15, 0.35, 1); bad = 1; }                              // 蓝
    }

    // 底图:Albedo 亮度 × 简单的半兰伯特,保留形体,又不让光照干扰“材质值”的判断
    half ndl = dot(normalize(i.normalWS), GetMainLight().direction) * 0.5 + 0.5;
    half3 baseCol = Luminance(albedo).xxx * (0.35 + 0.65 * ndl);

    // 屏幕空间斜条纹:一眼能和“本来就是这个颜色的材质”区分开
    half stripe = frac((i.positionCS.x + i.positionCS.y) / 14.0) < 0.5;
    half3 c = lerp(baseCol, flag, bad * lerp(0.35, 0.9, stripe));
    return half4(c, 1);
}
void main()
{
    vec3 albedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb;   // 线性
#if defined(_METALLICSPECGLOSSMAP)
    float metallic = texture(_MetallicGlossMap, uv).r;
#else
    float metallic = _Metallic;
#endif

    vec3 range = _PBRValidateRange.w > 0.0 ? _PBRValidateRange.xyz : DEFAULT_RANGE;
    // 回到 sRGB 再比较,和美术在 Photoshop/Substance 里看到的数值一致
    float lumS = LinearToSRGB(vec3(Luminance(albedo))).x;
    float maxS = LinearToSRGB(vec3(max(albedo.r, max(albedo.g, albedo.b)))).x;

    vec3 flag = vec3(0.0); float bad = 0.0;
    if (metallic > METAL_GRAY_LOW && metallic < METAL_GRAY_HIGH) { flag = vec3(1, 0.85, 0); bad = 1.0; }   // 黄
    else if (metallic >= METAL_GRAY_HIGH)
    {
        if (maxS < range.z) { flag = vec3(1, 0, 1); bad = 1.0; }                                          // 品红
    }
    else
    {
        if (lumS < range.x)      { flag = vec3(1, 0.1, 0.1); bad = 1.0; }                                // 红
        else if (lumS > range.y) { flag = vec3(0.15, 0.35, 1); bad = 1.0; }                              // 蓝
    }

    // 底图:Albedo 亮度 × 简单的半兰伯特,保留形体,又不让光照干扰“材质值”的判断
    float ndl = dot(normalize(normalWS), GetMainLight().direction) * 0.5 + 0.5;
    vec3 baseCol = vec3(Luminance(albedo)) * (0.35 + 0.65 * ndl);

    // 屏幕空间斜条纹:一眼能和“本来就是这个颜色的材质”区分开
    float stripe = fract((gl_FragCoord.x + gl_FragCoord.y) / 14.0) < 0.5 ? 1.0 : 0.0;
    vec3 c = mix(baseCol, flag, bad * mix(0.35, 0.9, stripe));
    fragColor = vec4(c, 1.0);
}

几个设计考虑:

  • 属性名与 URP Lit 完全一致(_BaseMap、_BaseColor、_Metallic、_MetallicGlossMap),并声明了同名关键字 _METALLICSPECGLOSSMAP。因此它可以直接“替换”Lit:在材质上把 Shader 换成它,所有贴图和数值都保留;更重要的是可以作为 RenderObjects 的 Override Shader 使用。
  • 比较在 sRGB 空间进行。采样得到的是线性值,用 LinearToSRGB 转回 sRGB 再和阈值比较,这样阈值可以直接使用美术熟悉的 0~255 数值。
  • 阈值是全局变量。如果写成材质属性,在 Override Shader 模式下,原材质上不存在这些属性,取不到值;全局变量由 C# 统一设置,w 分量为 0 时使用默认值。
  • 底图只保留亮度和简单的形体明暗。校验视图的目的是看材质值,彩色光照和高光只会干扰判断;斜条纹是屏幕空间的,可以和“本来就是红色的材质”区分开。

把它作用到整个场景,用的是 URP 14 自带的 Render Objects Renderer Feature 的 Override Mode = Shader。与 Override Material 不同,Override Shader 会保留每个物体原材质的属性值,只替换 Shader,这正是校验视图需要的。在 Renderer 资产上手动添加时,按下列设置填写:

  1. 选中项目使用的 Universal Renderer Data 资产,Add Renderer Feature › Render Objects。
  2. Event 选 AfterRenderingOpaques;Filters › Queue 选 Opaque,Layer Mask 选 Everything。
  3. Overrides › Override Mode 选 Shader,Shader 选 Cookbook/Ch13/PBRValidate,Pass Index 为 0。
  4. 需要校验时勾选该 Feature,平时取消勾选即可。

配图脚本里用代码做了同样的配置:

Assets/Cookbook/Ch13/Editor/Ch13Shots.cs(节选:validate_feature)
// 用 RenderObjects(Override Mode = Shader)把整个场景的不透明物体用 PBRValidate 重画一遍。
// 与在 Renderer 资产的 Inspector 里手动添加 Render Objects Feature 并这样填写等价。
static void AddValidateFeature(Shot s)
{
    Shader.SetGlobalVector("_PBRValidateRange", new Vector4(30f / 255f, 240f / 255f, 180f / 255f, 1f));
    var f = s.Feature<RenderObjects>();
    f.settings.Event = RenderPassEvent.AfterRenderingOpaques;
    f.settings.filterSettings.RenderQueueType = RenderQueueType.Opaque;
    f.settings.filterSettings.LayerMask = ~0;
    f.settings.overrideMode = RenderObjects.RenderObjectsSettings.OverrideMaterialMode.Shader;
    f.settings.overrideShader = Shader.Find("Cookbook/Ch13/PBRValidate");
    f.settings.overrideShaderPassIndex = 0;
    f.Create();   // 改完 settings 要重新 Create,Feature 才会按新设置建 Pass
}
图 13-14左:正常着色;右:用 Render Objects(Override Shader)把 PBRValidate 应用到整个场景。过暗的黑球标红、纯白球标蓝、金属度 0.5 的球标黄、暗色金属标品红;正常的电介质、正常金属和砖墙地面保持灰度显示;后方刷漆金属环面上,只有漆皮剥落的过渡边缘出现黄色——这是允许的灰区。

后方环面的结果值得注意:它使用的 Mask 贴图(案例 13.1 生成)在过渡带上有 0~1 之间的金属度,校验视图把这些边缘标成黄色。这不是错误,而是提醒你:过渡带的宽度是否合理?如果整片都是黄色,说明过渡带太宽了。校验工具的作用是把问题“显影”,最终判断仍然需要人来做。

注意Override Shader 模式下,物体原材质的局部关键字能否带到新 Shader,取决于新 Shader 是否声明了同名关键字。本例的环面正确显示了金属区域,说明 _METALLICSPECGLOSSMAP 的状态被带过来了;如果你的校验 Shader 没有声明这个关键字,所有使用金属度贴图的 Lit 材质都会退回到 _Metallic 滑条的值,校验结果就不可信。

案例 13.3批量检查贴图导入设置入门单独打开 ↗

目标:写一个编辑器菜单,按命名约定检查贴图的 sRGB 和 Normal Map 设置,并检查材质的法线槽里是否放了非法线贴图。

13.2 和 13.5 节的错误有一个共同点:它们都可以通过检查导入设置在“进场景之前”发现。前提是项目有统一的命名约定,例如颜色贴图以 _albedo、_basecolor 结尾,数据贴图以 _mask、_roughness 结尾,法线贴图以 _normal 结尾。

Assets/Cookbook/Ch13/Editor/PBRTextureAudit.cs(节选:rules)
// 后缀 -> 期望:颜色类贴图开 sRGB;数据类贴图关 sRGB;法线贴图必须是 Normal Map 类型
static readonly string[] ColorSuffix = { "_albedo", "_basecolor", "_diffuse", "_color", "_emission" };
static readonly string[] DataSuffix  = { "_mask", "_metallic", "_roughness", "_smoothness", "_ao", "_occlusion", "_height" };
static readonly string[] NormalSuffix = { "_normal", "_nrm", "_n" };

public static List<string> AuditFolder(string folder)
{
    var issues = new List<string>();
    foreach (var guid in AssetDatabase.FindAssets("t:Texture2D", new[] { folder }))
    {
        string path = AssetDatabase.GUIDToAssetPath(guid);
        if (!(AssetImporter.GetAtPath(path) is TextureImporter ti)) continue;
        string n = System.IO.Path.GetFileNameWithoutExtension(path).ToLowerInvariant();

        if (NormalSuffix.Any(n.EndsWith))
        {
            if (ti.textureType != TextureImporterType.NormalMap)
                issues.Add($"{path}: 名字是法线贴图,但 Texture Type 不是 Normal Map(会被当颜色做 sRGB 解码,也不会按法线格式压缩)");
        }
        else if (DataSuffix.Any(n.EndsWith))
        {
            if (ti.sRGBTexture)
                issues.Add($"{path}: 数据贴图开启了 sRGB,金属度/光滑度/AO 会被错误地“解码变暗”");
        }
        else if (ColorSuffix.Any(n.EndsWith))
        {
            if (!ti.sRGBTexture)
                issues.Add($"{path}: 颜色贴图关闭了 sRGB,颜色会显得发灰、偏亮");
        }
    }
    return issues;
}

// 材质的法线槽里放了非 Normal Map 类型的贴图(Inspector 会提示 Fix Now,但批量导入的材质常被忽略)
public static List<string> AuditMaterials(string folder)
{
    var issues = new List<string>();
    foreach (var guid in AssetDatabase.FindAssets("t:Material", new[] { folder }))
    {
        var mat = AssetDatabase.LoadAssetAtPath<Material>(AssetDatabase.GUIDToAssetPath(guid));
        foreach (var prop in new[] { "_BumpMap", "_DetailNormalMap" })
        {
            if (!mat.HasProperty(prop) || !(mat.GetTexture(prop) is Texture2D tex)) continue;
            if (AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(tex)) is TextureImporter ti && ti.textureType != TextureImporterType.NormalMap)
                issues.Add($"{mat.name}.{prop} = {tex.name}: 贴图不是 Normal Map 类型");
        }
    }
    return issues;
}

菜单 Cookbook › Ch13 › Audit PBR Textures 会检查 Project 窗口中选中的文件夹(未选中时检查整个 Assets),把问题输出为警告。工具只报告、不自动修改,因为导入设置的改动会触发重新导入,并可能改变已经调好的外观,应当由人确认。配图脚本在拍摄砖墙配图时对配套贴图目录运行了一次,结果为零个问题——这是因为配套工程用 AssetPostprocessor 在导入时就自动设好了。两者配合使用效果最好:AssetPostprocessor 保证新导入的贴图正确,检查工具负责找出历史遗留和被手动改错的贴图。

案例 13.4砖墙 PBR 套图入门单独打开 ↗

目标:用配套的 bricks 套图(Albedo、Normal、Mask、Height)在 URP Lit 中组装一面砖墙,观察 Mask 和高度图各自的贡献。

配套砖墙贴图由 tools/gen_textures.py 程序生成,结构与刷漆金属类似:一张“砖块/灰缝”结构图派生出所有通道。Albedo 中砖块为不同深浅的红褐色、灰缝为浅灰;Height 中砖块高、灰缝低;Normal 由 Height 求导得到;Mask 的 R 全为 0(砖墙没有金属),G 在灰缝附近降低(AO),A 中砖块光滑度约 0.25~0.4、灰缝约 0.1。

图 13-15砖墙套图(URP Lit)。左:只有 Albedo + Normal,光滑度为默认的 0.5,墙面和地面有塑料般的反光;中:+ Mask,光滑度与 AO 来自贴图,砖墙变得干燥、灰缝更暗;右:+ Height 作为视差贴图(Parallax 0.05),地面的砖块在掠射角下有了真实的高低错落。

左图是一个非常常见的“半成品”状态:只有颜色和法线时,Lit 的默认光滑度 0.5 对砖块来说太高了,掠射角下地面泛着一层塑料光。中图中 Mask 提供的低光滑度让砖块回到了“干燥的陶土”,灰缝的低 AO 让结构更清晰。右图的视差映射(URP Lit 使用的是单步偏移的 ParallaxMapping,第 7 章有详细介绍)在掠射角下效果最明显,代价是一次额外的高度图采样,且在轮廓处没有真实的几何变化。

组装一套材质时,可以按下面的顺序检查:先单独看 Albedo(Rendering Debugger 的 Albedo 视图,确认没有画进光照、亮度在范围内);再看 Mask 各通道(金属度是否基本为黑白,光滑度是否有足够变化,是否把粗糙度当成了光滑度);再看法线(用从上方照射的灯确认凹凸方向);最后在中性光照和项目的典型光照下整体检查,并用校验 Shader 过一遍。

本章小结

  • PBR Shader 的物理正确性以输入合理为前提;校准包括数据层(导入设置、通道含义)、取值层(数值范围)、观察层(光照环境)。
  • 颜色贴图开 sRGB,数据贴图关 sRGB,法线贴图设为 Normal Map 类型;Mask 被错误解码时,所有中间值都会被系统性压低。
  • Mask Map 打包时注意光滑度与粗糙度的互换、压缩格式的通道串扰;URP Lit 读 MetallicGlossMap 的 R/A 与 OcclusionMap 的 G,一张 R/G/A 打包的 Mask 可以同时用于两个槽。
  • Albedo 避免纯黑纯白,常用检查范围为 sRGB 约 30~240(Substance 指南)或线性亮度 0.01~0.90(URP 默认);金属的 Albedo 即 F0,应当明亮;金属度基本应为 0 或 1。
  • 法线贴图常见错误:Y 翻转(只在上下方向的光照下明显)、未标记 Normal Map(被 sRGB 解码而整体倾斜)、强度过高、切线空间不一致。
  • 判断材质要在中性光照下进行并用工具检查数值;Rendering Debugger 提供材质属性视图与 Albedo/Metallic 校验,自写 Shader 需支持 DEBUG_DISPLAY 才能参与。
  • 用 Render Objects 的 Override Shader 模式可以把自定义校验 Shader 一键作用于整个场景,同时保留每个材质的属性值。

练习

  1. 给 PBRValidate 增加一条规则:光滑度贴图在整个物体上的方差过小(几乎是常数)时用另一种颜色提示。思考这条规则应该在 Shader 中逐像素判断,还是在 C# 工具中对贴图做统计。
  2. 修改 PBRTextureAudit,对“名字以 _normal 结尾但 Swizzle 设置了 OneMinusG”的贴图给出提示,并在报告中区分“确定是错误”和“需要人工确认”两类问题。
  3. 为 PaintedMetalGenerator 增加一层“底漆”:漆皮剥落的边缘先露出一圈浅灰色的底漆,再露出金属。底漆在 Albedo、Mask、Normal 中分别应该如何表现?
  4. 搭建一个 URP 的 Look-dev 场景:中性灰天空、可旋转的白色主光、中性灰参照球与铬球,并用 Volume 提供“无后处理”和“项目后处理”两种配置,用它重新检查本章的刷漆金属和砖墙材质。