ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Python从单张图生成PBR纹理套装:完整流程与Unity验证

用Python从单张图生成PBR纹理套装:完整流程与Unity验证 做游戏场景原型时我经常遇到一种尴尬情况手里只有一张概念图或参考图却需要在半天内把它变成一套可以放进引擎的 PBR 纹理。很多朋友看到游戏里那些精细的地面、墙面和角色贴图都会好奇“这个纹理到底是怎么画出来的”。其实在一个原型阶段并不一定需要手工逐像素绘制完全可以从一张图中把“纹理”抽出来再通过算法拆成漫反射、法线、粗糙度、金属度等多张贴图。这篇文章就把我的完整实操流程拆开讲包含原理、Python 脚本、Unity 验证步骤和常见坑点希望给准备做场景原型或想理解 PBR 纹理工作流的读者一些参考。1. 背景与核心概念1.1 什么是 PBR 纹理为什么需要“一套”而不是“一张”PBR 全称是 Physically Based Rendering也就是基于物理的渲染。在 Unity、Unreal、Blender 这类主流引擎中一个标准材质通常不是靠一张贴图走天下而是由多张纹理共同参与光照计算漫反射贴图Albedo / Base Color描述物体本身的颜色不包含光照信息。法线贴图Normal Map通过 RGB 编码表面法线方向让低模表面呈现高模的细节凹凸。粗糙度贴图Roughness控制表面高光的锐利程度越白越粗糙越黑越光滑。金属度贴图Metallic区分导体和非导体区域金属区域反射环境更明显。高度图Height Map用于视差映射或曲面细分描述表面真实位移。环境光遮蔽贴图AO模拟物体缝隙、角落的阴影增强立体感。所以当你说“从一张图生成纹理”时本质上不是把一张图片直接丢给引擎而是把它拆解成多张互相配合的贴图。这里的关键思路是一张原图中往往同时包含颜色信息、明暗信息和边缘信息我们可以通过图像处理算法把这些信息分别提取出来再转换成不同贴图。1.2 单张图生成纹理的适用场景这种操作最早在技术美术圈比较常见后来很多游戏原画师也会用类似方法做快速效果预览。适合的场景包括关卡原型阶段先用占位纹理验证光照和场景氛围。独立游戏开发中预算不足但需要快速铺量。将手绘概念图快速变成场景材质节省前期调参时间。学习 PBR 工作流时用现成图片练习通道拆分。但它也有明显边界毕竟从单张图无法还原出真实物体在多个方向下的光照信息和深度信息生成的法线贴图只能算“近似值”。因此本文的定位是原型级生成方案正式项目中的高质量 PBR 资产仍然建议使用 Substance Painter、Maya 烘焙或专业扫描流程。理解这条边界很重要否则你可能会把自动生成的结果直接用于特写镜头最终效果达不到预期。1.3 需要掌握的基础概念在继续阅读前建议对下面几个概念有基本了解灰度图与通道一张 RGB 图可以拆成 R、G、B 三个通道也可以转成灰度图。粗糙度、金属度、高度图都可以用灰度信息表达。卷积与梯度法线贴图的核心计算思路是用相邻像素的明暗差判断表面朝向这一步通常用 Sobel 算子完成。sRGB 与线性空间颜色贴图通常需要 sRGB 编码而粗糙度、金属度、AO 等数据贴图必须在非线性模式下导入否则渲染结果会偏灰。我建议你不用急着把这些问题一次搞懂后面实际跑代码时再回头对照会更容易建立直觉。2. 环境准备与版本说明2.1 软件与库版本本文示例以常见环境为例具体版本需要根据你的项目实际情况调整操作系统Windows 10/11、macOS、Linux 均可Python 脚本不依赖特定系统。Python3.9 或更高版本。OpenCV4.x 版本用于图像读取、灰度转换、Sobel 卷积。NumPy任意新版本主要用于数组运算。Pillow用于图像格式转换和保存。Unity2019 LTS 或更新版本用于材质验证。如果你用 Blender 或 Unreal同样可以对照操作。为了不浪费篇幅这里直接给最小安装命令pip install opencv-python numpy pillow如果你用的是 Anaconda也可以创建虚拟环境后安装。建议把输入图片放在input文件夹输出结果放在output文件夹方便管理。2.2 示例项目结构为了让你能照着跑我在这里给出一个简单的目录结构texture-generator/ ├── input/ │ └── concept.png ├── output/ │ ├── albedo.png │ ├── normal.png │ ├── roughness.png │ ├── metallic.png │ ├── height.png │ └── ao.png ├── generate_textures.py └── requirements.txtconcept.png是一张你准备的参考图最好是表面纹理清晰、明暗关系明确的图片。例如一块石板、一面砖墙或一件铠甲的局部特写。如果是带有大量后期光影和色彩滤镜的插画生成效果会受影响因为光影会被误当成颜色或粗糙度信息。3. 核心原理从图像到贴图的关键拆分3.1 为什么能用明暗信息生成法线贴图先来理解法线贴图的原理。一个平面的法线天然是 (0, 0, 1)也就是垂直向上。如果表面有一个凸起凸起侧面的法线会向左右或上下偏转。法线贴图就是把这些偏转后的法线方向编码成 RGB 颜色R 通道记录 X 方向偏移G 通道记录 Y 方向偏移B 通道记录 Z 方向偏移。问题在于我们拿到一张普通图片时并没有现成的三维高度信息。但我们可以用一个假设图片中越亮的地方越高越暗的地方越低。这样图片就变成了一张“高度图”。接着用 Sobel 算子计算高度图在 X 和 Y 方向上的梯度梯度值大说明表面起伏剧烈。梯度值接近 0说明表面平坦。梯度方向决定法线偏转方向梯度大小决定偏转强度。这就是从单张图生成法线贴图的数学基础。实际生产中的更高阶做法是通过多张不同方向的光照照片重建法线这就是 photometric stereo但单张图方案在原型阶段已经够用。3.2 各贴图如何从图片中提取我的习惯是把单张图拆成三个层次来看颜色层保留 RGB 信息去除高光和阴影得到 albedo。亮度层转灰度再通过反色、对比度调整得到 height 和 roughness。边缘层通过梯度或高反差保留得到 normal 和 ao。这里没有标准答案不同图片生成策略略有差异。比如粗糙度图通常会让边缘区域偏白平坦区域偏黑金属度图则需要借助颜色阈值判断比如纯黑区域基本不是金属高亮度银白色区域大概率是金属。3.3 命名规范与通道约定生成多张贴图之后一定要约定命名规范。我的建议是使用 Unity 和 Unreal 都容易识别的后缀concept_albedo.png concept_normal.png concept_roughness.png concept_metallic.png concept_height.png concept_ao.png同时要熟记各引擎的通道约定例如 Unity 的标准着色器里Metallic 贴图的 Alpha 通道默认作为 AORoughness 如果放在 Smoothness 里则需要反转。导出时如果不注意这些细节很容易出现“贴图都在材质却怎么调都不对”的情况。4. 完整实战Python 实现 PBR 纹理套装生成4.1 准备输入图片我在实际测试时使用的是一张具有明显砖块凹凸感、表面有颜色差异的老旧墙面照片。这里建议你用类似纹理清晰的图片最好是正方形方便后续缩放到 1024×1024因为游戏纹理通常要求宽高为 2 的幂。如果输入图片尺寸不是正方形脚本会自动居中裁剪到正方形然后再缩放。裁剪逻辑请根据自己的素材风格调整核心目的是尽可能保留纹理中心区域。4.2 创建项目目录打开终端进入你希望存放项目的目录执行mkdir texture-generator cd texture-generator mkdir input output接着把参考图片放到input目录并命名为concept.png。4.3 编写核心 Python 脚本在项目根目录创建generate_textures.py完整代码如下。我会在代码后面详细解释每一段的含义。import cv2 import numpy as np from pathlib import Path # ------------------------------- # 1. 读取与预处理 # ------------------------------- input_path Path(input/concept.png) output_dir Path(output) output_dir.mkdir(exist_okTrue) img cv2.imread(str(input_path), cv2.IMREAD_COLOR) if img is None: raise FileNotFoundError(f无法读取图片{input_path}) # 转换为 RGB 顺序避免 BGR 通道混淆 img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 裁剪到正方形 h, w, _ img_rgb.shape side min(h, w) start_y (h - side) // 2 start_x (w - side) // 2 img_crop img_rgb[start_y:start_y side, start_x:start_x side] # 缩放到 1024x1024便于引擎使用 size 1024 img_resized cv2.resize(img_crop, (size, size), interpolationcv2.INTER_AREA) # ------------------------------- # 2. 生成 Albedo漫反射 # ------------------------------- # 降低饱和度并减弱高光作为粗略的 albedo hsv cv2.cvtColor(img_resized, cv2.COLOR_RGB2HSV) hsv hsv.astype(np.float32) hsv[:, :, 1] * 0.85 # 降低饱和度 hsv np.clip(hsv, 0, 255).astype(np.uint8) albedo cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB) # ------------------------------- # 3. 生成 Height高度图 # ------------------------------- gray cv2.cvtColor(img_resized, cv2.COLOR_RGB2GRAY) # 用高斯模糊去除高频噪点会让高度图更平滑 gray_blur cv2.GaussianBlur(gray, (5, 5), 1.0) height gray_blur.copy() # 增加对比度让明暗更分明 height cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)).apply(height) # ------------------------------- # 4. 利用 Sobel 梯度生成法线贴图 # ------------------------------- # 分别计算 x 和 y 方向的梯度 grad_x cv2.Sobel(height, cv2.CV_32F, 1, 0, ksize3) grad_y cv2.Sobel(height, cv2.CV_32F, 0, 1, ksize3) # 根据梯度构想法线向量z 方向固定为一个常数值 strength 2.0 normal_x -grad_x * strength normal_y -grad_y * strength normal_z np.full_like(grad_x, 1.0 * strength) # 归一化到单位长度 norm np.sqrt(normal_x**2 normal_y**2 normal_z**2) normal_x / (norm 1e-8) normal_y / (norm 1e-8) normal_z / (norm 1e-8) # 将法线从 [-1, 1] 映射到 [0, 255] normal_map np.zeros((size, size, 3), dtypenp.float32) normal_map[:, :, 0] (normal_x * 0.5 0.5) * 255.0 normal_map[:, :, 1] (normal_y * 0.5 0.5) * 255.0 normal_map[:, :, 2] (normal_z * 0.5 0.5) * 255.0 normal_map np.clip(normal_map, 0, 255).astype(np.uint8) # ------------------------------- # 5. 生成 Roughness粗糙度 # ------------------------------- # 边缘信息强的地方通常更粗糙平坦区域相对光滑 grad_magnitude cv2.magnitude(grad_x, grad_y) grad_magnitude cv2.normalize(grad_magnitude, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) # 基础粗糙度从亮度读取再融合边缘强度 base (255 - gray_blur).astype(np.float32) # 暗部粗糙亮部光滑这里只是基础假设 roughness base * 0.6 grad_magnitude * 0.4 roughness np.clip(roughness, 0, 255).astype(np.uint8) # ------------------------------- # 6. 生成 Metallic金属度 # ------------------------------- # 使用去饱和后的亮度信息做简单判断 # 过暗区域视为非金属明亮高饱和区域可标记为金属候选 # 这里仅做示例正式场景应该基于素材语义手动抠图 sat hsv[:, :, 1] # 当前 HSV 的饱和度 val cv2.cvtColor(img_resized, cv2.COLOR_RGB2GRAY) metallic np.zeros_like(gray) mask (val 200) (sat 60) metallic[mask] 255 # 做一次膨胀使金属区域连续 kernel np.ones((3, 3), np.uint8) metallic cv2.dilate(metallic, kernel, iterations1) # ------------------------------- # 7. 生成 AO环境光遮蔽 # ------------------------------- # 思路边缘、缝隙越亮的位置 AO 越低越黑用反色边缘做近似 ao cv2.GaussianBlur(grad_magnitude, (0, 0), sigmaX5) ao 255 - ao ao cv2.normalize(ao, None, 120, 255, cv2.NORM_MINMAX).astype(np.uint8) # ------------------------------- # 8. 保存所有贴图 # ------------------------------- cv2.imwrite(str(output_dir / albedo.png), cv2.cvtColor(albedo, cv2.COLOR_RGB2BGR)) cv2.imwrite(str(output_dir / normal.png), cv2.cvtColor(normal_map, cv2.COLOR_RGB2BGR)) cv2.imwrite(str(output_dir / roughness.png), roughness) cv2.imwrite(str(output_dir / metallic.png), metallic) cv2.imwrite(str(output_dir / height.png), height) cv2.imwrite(str(output_dir / ao.png), ao) print(完成输出文件位于 output 目录。)4.4 脚本关键步骤解释这段脚本的每一步都对应了 PBR 纹理的生成思路。读取图片后我并不直接使用 BGR 顺序因为 OpenCV 默认读取结果是 BGR用cv2.cvtColor转成 RGB 可以避免后续保存颜色错乱。裁剪到正方形是为了纹理平铺方便。游戏里的纹理大部分需要 tiling也就是可以被引擎重复平铺宽高比不一致会让平铺后的接缝位置变得不可控。缩放操作使用cv2.INTER_AREA它在缩小图片时能保留更多细节降低摩尔纹。Albedo 的生成采用的是降低饱和度策略。真实 PBR 工作流中albedo 是不允许带有光照信息的但这里我们无法从单张图里彻底分离光照因此只做简单去饱和处理让颜色更接近“固有色”。如果你追求更高精度可以在原图上手动修复高光和阴影区域。Height 图使用 CLAHE 自适应直方图均衡化主要目的是提升局部对比度让后续梯度计算更明显。这个步骤取决于素材。如果素材本身明暗对比很好可以不用 CLAHE。法线贴图的生成是整段代码的核心。cv2.Sobel计算梯度后x 和 y 方向的梯度分别表示高度变化率法线的 x、y 分量与梯度相反。为什么是相反因为当高度向右递减时表面朝向应该向右上方倾斜用负号可以修正方向。这里的strength控制了法线凹凸强度值越大凹凸感越强但过大会导致法线方向不连续出现光照断层。normal_z 固定为常数代表表面总体朝上。如果法线全部来自高度图那么 z 分量不应该为零否则平面会变成侧立状态。归一化时加上一个极小值1e-8是为了防止除以零。粗糙度和金属度的生成方式比较“经验化”。粗糙度不是物理上从单张图能直接得出的数据这里我用“亮度偏低的地方更粗糙梯度大的地方更粗糙”作为假设。金属度则是根据“高亮且低饱和”来猜测金属区域这个方法只适合特定类型素材比如拉丝金属、生锈钢板遇到复杂材质时建议手动绘制遮罩。AO 的核心是反色边缘。缝隙和凹槽通常光照更少而在高度图中对应梯度较大的区域将这些区域压暗就是最简单的 AO 近似。4.5 运行脚本确保当前目录是项目根目录执行python generate_textures.py正常情况下会看到完成输出文件位于 output 目录。同时output目录下会出现 6 张 PNG 图片。你可以先简单看一下这几张图albedo 应该保留了原图颜色normal 偏蓝紫色roughness 偏灰height 是黑白图metallic 大片黑色、只有高亮低饱和区域是白色ao 整体偏亮、缝隙处偏暗。5. 在 Unity 中验证与材质创建5.1 创建材质并导入贴图生成图片只是第一步真正能看出效果还要放到引擎里验证。以 Unity 为例先创建一个 Standard 或 URP/Lit 材质然后把生成结果拖到对应槽位。需要注意Unity 的导入设置对渲染结果影响很大尤其是线性空间的项目。对于不同贴图导入设置建议如下贴图类型纹理类型sRGBAlphaalbedo.pngDefault开启默认normal.pngNormal Map关闭默认roughness.pngDefault关闭默认metallic.pngDefault关闭默认height.pngDefault关闭默认ao.pngDefault关闭默认在 Unity 中Normal Map 类型会自动把贴图转换为适合法线解码的格式同时会有一个Create from Grayscale选项但这里我们已经有 RGB 法线图不需要勾选。5.2 常见渲染结果不符的原因如果材质看起来不对劲第一反应应该是检查导入设置而不是急着调 Shader。粗糙度、金属度这种数据贴图一旦被当作 sRGB 处理整体亮度会被提亮导致材质看起来像蒙了一层灰。解决方法是在导入设置里把 sRGB 关掉或者把颜色空间切换成 Linear。另外标准材质中 Smoothness 默认可能使用粗糙度反值。如果你的粗糙度图导入后效果正好相反可以考虑在 Shader 里把 Smoothness 设为 Roughness 的反向或者在导出时对 roughness 做一次反色再输出。# 如果需要反向粗糙度可取消下面注释 # roughness 255 - roughness6. 常见问题与排查思路6.1 常见问题速查表问题现象常见原因解决思路法线贴图光照方向反了梯度符号取反修改normal_x grad_x * strength再重新导出材质整体偏灰数据贴图被当成 sRGB导入设置关闭 sRGB切换 Linear 空间粗糙度效果反了引擎使用 Smoothness导出前对 roughness 做一次反色金属区域不连续金属度阈值不合理修改val 200和sat 60的阈值或手动补遮罩贴图接缝明显输入图片不是无缝平铺图使用cv2.warpAffine做偏移混合或使用 Substance 的平铺生成模型凹凸感太弱strength 值过低适当增大strength一般 2-5 之间高光区域像素溢出法线归一化失败检查 norm 是否出现 0对应位置需要加 epsilon6.2 法线方向反了的判断方法如果你发现模型高光位置左右颠倒仔细看凹凸方向也相反那基本就是法线贴图 Green 通道方向问题。在 Unity 中还有一种常见情况不同平台的 UV 坐标系 Y 轴方向不同导致法线贴图绿色通道需要翻转。这个问题的典型表现是同一套贴图在 Windows 编辑器里正常打包到某些移动端平台后凹凸方向不一致。解法是在导入设置里勾选或取消Flip Green Channel或者在导出时对 normal_map 的 G 通道取反normal_map[:, :, 1] 255 - normal_map[:, :, 1]6.3 如何保证图片可以无缝平铺如果希望纹理可以平铺需要输入图片本身满足 tileable 条件也就是左右边缘、上下边缘接在一起时过渡自然。常见做法是使用cv2.remap做镜像混合把图片四周边缘向中心偏移一定像素后混合。这里给一个最小实现# 对输入图做简单的无缝处理适用于正方形图片 def make_tileable(img, blend_width32): rows, cols img.shape[:2] # 左右边缘混合 left img[:, :blend_width, :] right img[:, -blend_width:, :] for i in range(blend_width): alpha i / blend_width img[:, i, :] (1 - alpha) * right[:, i, :] alpha * left[:, i, :] img[:, -blend_width i, :] alpha * right[:, -blend_width i, :] (1 - alpha) * left[:, -blend_width i, :] return img这个方案比较粗糙但对原型足够了。若要做更高质量无缝纹理建议使用图像修复或专业工具。7. 最佳实践与工程建议7.1 开始写脚本前的素材判断不是所有图片都适合用这种方式生成 PBR 纹理。我在实际使用时遇到过两种情况第一种是干净的产品渲染图比如一块金属面板的斜视角特写。这种图光照很强高光和阴影非常明显直接降低饱和度当 albedo 会得到一块“带光照的颜色”放进引擎会显得很不真实。解决方法是在图库中选择正面平光的纹理素材或者先对高光区域做修复。第二种是手绘像素风概念图。这种图颜色很平几乎没有真实光影用梯度生成法线贴图时凹凸感往往很弱。这时可以先用 Photoshop 或 Krita 手动补一版灰度高度图让算法从这张高度图出发。所以在教程开始前我建议先花五分钟判断素材质量。如果图片本身就带有强烈的非均匀光照那后续所有贴图都会被这个光照污染。7.2 纹理参数保持可配置在脚本里strength、CLAHE clipLimit、roughness 融合比例、metallic 阈值这些参数都是硬编码的。实际项目里不同素材需要不同参数建议把它们统一提取为配置文件或者放在脚本顶部的CONFIG字典里。尤其当团队里有多个美术同学使用时统一的参数入口会减少很多沟通成本。CONFIG { texture_size: 1024, normal_strength: 2.5, roughness_base_weight: 0.6, roughness_edge_weight: 0.4, metallic_val_threshold: 200, metallic_sat_threshold: 60, ao_lower_bound: 120, }按参数化思路重构后后续调整效果就不需要改算法逻辑只需要改数值。这是工程化很重要的一步。7.3 数据贴图必须区分颜色空间颜色空间是我最想强调的一点因为生产环境里最容易出问题的不是算法而是导入流程。Unity 默认使用线性颜色空间时所有输入材质纹理都会被引擎做一次 color space 处理。如果 roughness 和 metallic 被识别为 sRGB它们的取值范围会被重新映射渲染效果就会偏移。在 Unity 中处理方式是手动把导入类型改成 Default并关闭 sRGB在 Unreal 中要确认贴图压缩设置是否符合数据类型。7.4 自动生成不代表最终资产自动生成贴图适合原型验证但如果是高精度展示、CG 渲染或商业游戏项目一定要在生成基础上手动精修。比如法线贴图需要在 Substance Painter 里叠加细节层metallic 需要根据材质语义做区域划分AO 需要结合模型实际结构重新烘焙。把这些流程沉淀为自动化的“粗装机”再配一条人工精修流水线效率提升才是最大的。7.5 版权与素材来源如果你使用网络图片作为输入需要先确认图片版权尤其是做商业项目时。很多图片素材网站明确禁止将图片用于纹理提取和游戏素材再加工直接使用会带来法律风险。建议优先使用自己拍摄的照片、已授权的素材库或完全生成的程序化纹理。涉及 AI 辅助生成时也要关注生成模型的授权范围这一点现在越来越重要。8. 总结与下一步学习路线从一张参考图生成整套 PBR 纹理并不神秘核心就是通道拆分和梯度计算。这篇文章带着你走了完整流程先用 Python 读取图片、裁剪到正方形、降低饱和度生成 albedo用 Sobel 算子生成法线贴图再从亮度、边缘信息中提取粗糙度、金属度、高度图和 AO最后在 Unity 中验证效果。整个过程大约几十行代码但足以帮助你理解 PBR 纹理工作流中的关键逻辑。如果你接下来想继续深入可以按这个顺序学习先深入学习 Sobel、Laplacian 等图像梯度算子理解法线生成的数学原理。然后尝试在实际项目中使用 Substance Painter 手绘材质理解自动生成与手工绘制的区别。再研究 Unity Shader 源码中 Normal Map 的 UnpackNormal 实现彻底搞懂法线贴图编码方式。最后可以尝试加入 AI 类工具辅助生成纹理但在使用前务必确认授权和平台合规要求。这块内容练习起来比较吃图片素材建议你平时看到适合的纹理就存下来建立一个从照片到 PBR 的转换工作流慢慢就会有自己的“贴图生成素材库”。下次再遇到“只有一张图但需要一套纹理”的需求时你也能直接上手跑通整个流程。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进