URP 渲染实战 / 第 13 章 PBR 材质制作与校准 / 案例 13.1
案例 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 解码) }; }
生成流程可以概括为“一张结构图,派生所有通道”:
- 结构:一张低频 fBm 噪声
n决定漆皮在哪里剥落。wear参数控制阈值,越大露出的金属越多;smoothstep给出一条有一定宽度的过渡带c(0 = 漆,1 = 金属)。另一张高频噪声g提供细碎的脏污变化。 - Albedo:漆色与金属色按
c混合。金属色故意保持明亮——金属的 Albedo 就是它的 F0,现实中常见金属的反射率都很高(13.4 节)。剥落边缘的过渡带略微变暗,模拟积灰。 - Mask:R 直接等于
c,过渡带上会有 0~1 之间的值,这是允许的(像素覆盖了一部分漆、一部分金属);G 在边缘带降低,模拟缝隙中的遮蔽;A 在漆和金属之间插值,并在边缘带降低,因为剥落边缘粗糙。 - Normal:漆皮有厚度,高度 = 1 − c;对高度做中心差分得到切线空间法线。注意 Unity 的切线空间 +Y 对应贴图的 +V 方向(OpenGL 约定),而
SetPixels的第 0 行是贴图底部,所以向上的差分就是 +Y,不需要翻转。 - 色彩空间:Albedo 创建为 sRGB 贴图(
linear: false),Mask 和 Normal 创建为线性贴图。生成时我们给的颜色本身就是 sRGB 值(美术在取色器里看到的数值),与导入设置保持一致。

第一格与第二格的差异最能说明金属度的作用:同样一块浅灰色区域,金属度为 0 时它是“浅灰色塑料”,漫反射占主导;金属度为 1 时它没有漫反射,颜色完全来自对环境的反射。第四格展示了程序化的价值:改两个参数就得到一套新的、各通道自洽的贴图。实际项目中,这类生成通常在 Substance Designer、Houdini 或自研工具中完成,原理相同。
本案例出自《URP 渲染实战》第 13 章「PBR 材质制作与校准」。在章节中阅读上下文 →