程序化纹理生成实战:用Python打造无缝PBR材质工具
artcraft 这个名字是我在为一个开放世界项目补材质库时随手起的。当时手绘的石头贴图已经攒了二十多张美术每次放大看都不满意——光影不统一明暗纹路各走各的铺到地形上像打了补丁。程序化生成这个方向我早想尝试趁项目间隙把工具搭了起来起名时没费太多心思art 加 craft艺术与手艺。后来这名字反而成了项目的灵魂因为我做的本质上是把“手艺感”写进代码。现在回头看artcraft 并不是一个多庞大的系统它是一个以节点和代码为核心的生成艺术工具主要输出 PBR 无缝贴图、2D 纹理变体以及各种可复用的图案。它解决的核心问题是传统手绘贴图在批量化生产时最让人头疼的三件事风格不一致、接缝难处理、变体成本高。适合的读者我猜主要是游戏开发、技术美术、独立制作者以及那些想接触生成艺术但还在边缘观望的人。1. 项目定位与核心设计思路1.1 artcraft 到底是什么简单点说artcraft 是一个面向纹理和材质生产的程序化生成工具。你可以把它理解成一块画布但不用鼠标笔刷而是用噪声函数、数学表达式、图层混合和掩码节点来作画。它输出的不是一副传统意义上的画而是一张或一组可平铺的图像再往后接 PBR 材质管线就能变成游戏场景里的岩石、地表、木纹、金属划痕。当时给我自己的定位很明确不做大而全的 DCC 工具只专注纹理领域。市面上已经有很多成熟的节点式工具我不可能也无意去对抗它们。artcraft 的出发点更朴素——用一套轻量级代码把高频重复的纹理生产自动化。比如某个场景需要 20 块颜色略有差异但纹路风格完全一致的岩石手工去画先不说工作量光保证风格统一就很考验人。程序化生成可以轻松做到同一个核心节点树换几个种子和颜色映射就能批量产出我想要的变体。工具的本体是一套基于 Python 的生成引擎外加一个 JSON 格式的节点图描述文件。节点图的表达能力足够做中度复杂度的纹理同时又避免了任何图形界面开发的成本。你写完节点图运行引擎得到几组通道图然后再交给渲染器去合成。1.2 为什么选程序化而不是手绘这个问题几乎每个看到项目的同事都会问一次。我的回答通常分三层。第一层是统一性。手绘贴图本身没有问题它的问题在于多张同风格贴图很难做到真正的统一。程序化生成没有这个问题因为所有变化都来源于同一组参数你能保证每一张变体之间的风格偏差完全可控。第二层是迭代效率。美术改一张手绘贴图可能要整体重画程序化改一个参数比如把 noise scale 从 3.5 改到 7.0整个纹理风格就变了再配合种子微调几十个变体瞬间就能出来。这种迭代速度对快节奏的项目来说太关键了。第三层是技术深度。程序化生成能够产出手绘很难做到的东西比如严格无缝的 tileable 贴图、基于物理的粗糙度与法线联动、以及多通道数据的一致性。简单说手绘更适合做“表达”而程序化更适合做“生产”。artcraft 的定位从一开始就在“生产”这一侧这也是它和纯艺术创作工具最本质的区别。1.3 设计取舍和技术栈artcraft 的早期版本我试过做成一个带交互界面的桌面应用后来放弃了。原因很简单界面耗费的时间远超功能本身。于是我把核心逻辑全部收敛到命令行和 Python 脚本中前端交互先从简这样项目能聚焦在最有价值的算法流程上。技术栈方面基础是 Python 3.9 和 numpy图像处理用 Pillow。噪声算法是自己实现的 Perlin 和 Worley没有直接依赖现成的生成艺术库原因后面会提到。整套系统的核心架构可以分为三层节点描述层JSON 文件描述节点图结构包括节点类型、参数、连线关系。计算执行层Python 引擎按拓扑顺序执行节点生成中间结果和最终输出图。通道导出层按 basecolor、normal、roughness、height 等通道打包输出适配引擎导入。这个分层思路如果你之后想给 artcraft 加 UI 或者扩展成 Web 服务都会很顺手。2. 核心原理与参数拆解2.1 从噪声函数到纹理骨架所有程序化纹理的基础几乎都是噪声函数。artcraft 里最常用的三类噪声是 Perlin 噪声、Worley 噪声以及它们组合出来的 FBM。Perlin 噪声可以理解成一个连续、平滑、可重复的随机场。普通随机数是每个像素独立蹦跳的Perlin 则会生成类似山丘起伏的连续图案它是岩石、地形、云朵这类自然纹理的骨架。Worley 噪声则长得完全不一样它把空间划分成无数个细胞每个细胞中心生成一个点再计算像素与最近点的距离得到的效果类似细胞壁、瓷砖缝、马赛克适合做石头缝隙、龟裂地表、鳞片这类结构感强的纹理。FBM全称是 Fractal Brownian Motion分形布朗运动这个名词听起来吓人实际就是把多个不同频率的噪声叠加到一起。低频噪声决定大形高频噪声叠加细节于是画面呈现出“远看有起伏近看有细节”的自然效果。这就像一张真实的山地照片既有山体的大轮廓又有碎石、树叶和土壤的细碎层次单靠一层噪声永远做不到这一点。2.2 无缝平铺的实现要点artcraft 输出的纹理必须能平铺。平铺的含义是把一张纹理像瓷砖一样左右上下拼接边界处看不出接缝。这个需求在游戏场景里几乎是刚需因为地形和墙体的大面积表面只能靠重复贴图覆盖。无缝的最朴素实现思路是让噪声在边界处保持连续。最简单的方法是使用周期性噪声即把噪声场生成在一个环形空间中让它在整数倍频上重复。这里有一个关键参数噪声的 frequency 必须取整数。比如 noise scale 设为 4 时噪声形状会在 4 个周期后循环正好平铺到边缘如果取 3.7 之类的小数边界就成了断崖接缝立刻原形毕露。除了整数频率我还在引擎里加了一组边界混合函数。具体做法是生成噪声时将四边各取一定宽度的区域做交叉淡入淡出。实际操作下来把频率设为整数还是最省事的方法边界混合只作为补充手段。2.3 核心参数速查表程序化纹理的参数数量可以非常多但如果核心理解透了大部分参数都是同一回事。artcraft 里我整理了这样一张速查表参数名作用推荐范围备注frequency噪声基础频率影响纹理大小1~8值必须是整数否则会出现接缝octavesFBM 叠加层数3~6越多细节越丰富性能开销越大lacunarity相邻两层的频率倍数2.0~3.0常用 2.0类似倍频程gain相邻两层的振幅衰减0.4~0.6控制细节相对大形的贡献度seed随机种子任意同参数不同种子可产出无穷变体warp扭曲强度0~1让纹理出现流动感避免太机械最常被低估的参数是 gain。它控制在整体画面中细节层的权重如果设得太大高频噪点会喧宾夺主设得太小细节又会糊成一片。我一般从 0.5 起步根据纹理用途调整到 0.45 或 0.55。3. 实操从零生成一张无缝岩石纹理这部分我直接展示一个完整的实操流程。目标是生成一张 1024x1024、无缝平铺的岩石地表纹理包含 basecolor、normal、roughness 三个通道。3.1 环境准备与工程结构artcraft 的运行环境非常简单只需要一个 Python 3.9 以上的解释器和两个库numpy、Pillow。建议使用虚拟环境mkdir artcraft_demo cd artcraft_demo python -m venv venv source venv/bin/activate pip install numpy pillow工程目录的组织方式我习惯这样artcraft_demo/ ├── nodes.py # 节点定义与计算逻辑 ├── engine.py # 节点图执行引擎 ├── texture.py # 纹理生成入口脚本 └── output/ # 输出图片目录实际跑起来的时候入口脚本读入一个 JSON 节点图描述engine 按依赖顺序依次执行最后输出通道图。为了演示方便我直接写一个单文件生成脚本逻辑上等价于一张小的节点图。3.2 噪声场与高度图生成第一步是生成灰度高度图它构成了纹理的高程信息后续颜色、法线、粗糙度全都由它派生。import numpy as np from PIL import Image def smoothstep(t): return t * t * (3.0 - 2.0 * t) def value_noise_2d(shape, res, seed): rng np.random.default_rng(seed) lattice rng.random((res[0] 1, res[1] 1)) coords np.indices(shape, dtypefloat) fx coords[0] / shape[0] * res[0] fy coords[1] / shape[1] * res[1] ix, iy fx.astype(int), fy.astype(int) tx, ty smoothstep(fx - ix), smoothstep(fy - iy) n00 lattice[ix, iy] n10 lattice[ix 1, iy] n01 lattice[ix, iy 1] n11 lattice[ix 1, iy 1] nx0 n00 * (1 - tx) n10 * tx nx1 n01 * (1 - tx) n11 * tx return nx0 * (1 - ty) nx1 * ty def fbm(shape, res, octaves, lacunarity2.0, gain0.5, seed0): total np.zeros(shape, dtypefloat) amplitude 1.0 frequency 1.0 max_value 0.0 for i in range(octaves): total amplitude * value_noise_2d(shape, (int(res[0] * frequency), int(res[1] * frequency)), seed i * 131) max_value amplitude frequency * lacunarity amplitude * gain return total / max_value这里有个细节值得说明fbm内部每层调用的value_noise_2d的 res 是逐层乘以 lacunarity 的所以在 octaves 为 5 时最高层的 res 可能达到 16以上。由于每一层的基础噪声频率都是整数整张图的平铺性质天然成立这比你事后修接缝要省事得多。有了 fbm 函数生成高度图就简单了shape (1024, 1024) height_map fbm(shape, (6, 6), octaves5, lacunarity2.0, gain0.5, seed2024) height_map (height_map - height_map.min()) / (height_map.max() - height_map.min()) Image.fromarray((height_map * 255).astype(np.uint8)).save(output/height_raw.png)height_map 是一个 0 到 1 的浮点数组转成 8bit 灰度图后你能看到清晰的岩石轮廓和颗粒感。3.3 法线贴图与粗糙度派生高度图只是第一步真正让模型在光照下产生立体感的是法线贴图。在 artcraft 里我没有引入复杂的法线生成算法只用了一个基于 Sobel 梯度计算的方法逻辑清晰效果也足够def height_to_normal(height_map, strength2.0): h, w height_map.shape normal np.zeros((h, w, 3), dtypefloat) # Sobel 梯度 gy -height_map[2:, 1:-1] * 1.0 height_map[:-2, 1:-1] * 1.0 gx -height_map[1:-1, 2:] * 1.0 height_map[1:-1, :-2] * 1.0 gx * strength gy * strength gz np.ones_like(gx) norm np.sqrt(gx * gx gy * gy gz * gz) normal[:, :, 0] -gx / norm normal[:, :, 1] -gy / norm normal[:, :, 2] gz / norm return normal注意这里我把法线向量的 z 分量默认设为 1然后归一化。这意味着在平坦区域法线指向正是视角方向梯度大的地方法线偏移模型表面就会产生岩石的立体起伏感。strength这个参数需要按用途调整如果模型在引擎里光照偏强strength 建议降到 1.0 到 1.5不然后期修法线会非常痛苦。粗糙度贴图的生成逻辑则基于一个实用的观察岩石凹陷和缝隙处通常会堆积尘土与碎石反射更粗糙凸起受风化作用表面相对光滑。所以 roughness 可以直接和高度挂钩再叠加少量噪声做非均匀变化roughness 0.4 0.4 * (1.0 - height_map) 0.2 * fbm(shape, (6, 6), octaves4, seed7) roughness np.clip(roughness, 0.05, 1.0)这行代码做完之后整张贴图的基本材质要素就齐了。另外为了生成 basecolor我又在高度图基础上做了一次色调映射高度低处偏暗褐色高度高处偏亮灰色然后叠一层低强度的 Worley 噪声来模拟岩石颗粒。basecolor np.zeros((*shape, 3), dtypefloat) dark_color np.array([0.25, 0.20, 0.18]) light_color np.array([0.72, 0.68, 0.62]) for c in range(3): basecolor[:, :, c] dark_color[c] * (1 - height_map) light_color[c] * height_map basecolor 0.06 * worley_noise_2d(shape, (8, 8), seed11)[:, :, np.newaxis] basecolor np.clip(basecolor, 0.0, 1.0)这套流程跑完后输出目录里会多出 4 张图height_raw、normal、roughness、basecolor。把这些图导入任意主流引擎的 PBR 流程中就能直接换成可用的材质球。4. 常见问题与排查技巧4.1 接缝问题为什么我的纹理平铺后还是看到缝这是程序化纹理里最常遇到也最容易让人上头的坑。我遇到过的原因主要有三个按出现频率排序第一个原因是 frequency 不是整数。噪声的周期与频率直接相关频率是小数噪声形状就截断了边界自然对不上。解决方案很简单把 fbm 的基础 res 设为整数比如 6、8、12不要在中间参数上使用 3.5、4.5 这类值。第二个原因是边界混合函数和噪声重复模式冲突。我早期版本里先做了边界混合后面又叠加了不同的噪声层导致混合区域像素跳变。这个和架构相关后来我把所有噪声统一改成同一周期模式接缝问题就少了。第三个原因是法线贴图采样精度。即使 basecolor 看起来无缝法线图在边界处如果出现了梯度跳变平铺时照样会有一条亮暗分界线。这类问题排查时肉眼很难发现我建议用引擎或者图片编辑软件里的平铺预览模式去检查直接看能不能接上。提示排查接缝时先把全部通道图层勾选掉只保留 basecolor 平铺确认颜色没缝后再单独查看法线通道。分层排查能帮你快速定位问题出现在哪个环节。4.2 性能问题高分辨率纹理生成太慢1024 分辨率的纹理生成通常只需要几秒但如果你把输出调到 4096 或者 8192再叠加 8 层 octaves速度会显著劣化。artcraft 早期的性能瓶颈主要集中在噪声采样和 Python 层循环。我做过两个优化直接让生成速度提升了接近一倍。第一个是内存复用。value_noise_2d里生成的 lattice 数组是每层最占资源的对象。我在实现中不再重复创建临时数组而是利用 numpy 的切片和视图操作直接在原数组上计算减少内存分配和复制。第二个是降采样预计算。对于低 octaves 层它们实际上是低频信号并不需要全分辨率采样。我以 1024 最终分辨率为基准前 3 层用 256x256 计算然后双线性插值放大到 1024再加入细节层。这样视觉上几乎无感知但生成时间能缩短 30% 以上。如果追求更高性能还可以把整个 FBM 计算挪到 GPU用着色器或者 CUDA 实现。不过对于静态纹理生产场景CPU 配合 numpy 已经足够GPU 优化的性价比不算高。4.3 参数一致性与批量变体实际生产中最容易翻车的是几个种子跑出来肉眼看着已经接近了但放回场景后整体节奏或明暗体感还是有细微差异。这种差异如果在手绘时代还好说程序化时代反而需要更严格地控制参数。我的做法是给每个纹理建立一张参数记录表包含所有节点参数的快照、种子、输出通道、目标用途。之后无论怎么调参都能回到一个可复现的状态。artcraft 里我甚至专门做了节点图描述文件的自动存档每次生成都会把对应的 JSON 存到 output 目录里。这样半年后再去看某张贴图也能知道它是怎么来的。另一个经验是变体生成时不要让所有参数都随机化。正确的做法是固定核心风格参数比如频率和 octaves只随机 seed、warp 值和色彩映射表。核心参数一变风格就变了它就不是变体而是新纹理了。5. 扩展应用与经验总结5.1 从纹理到完整材质artcraft 最初只为生成单张贴图但跑通后我发现它真正适合的土壤是材质生产链。一套完整的 PBR 材质通常包含 basecolor、normal、roughness、metalness、height、AO 六个通道每个通道的生成逻辑可以不共通但它们必须共享同一套空间匹配关系。这一点 artcraft 天然满足因为所有通道图都是基于同一个噪声场派生出来的。这也是我强烈建议不要直接手动生成六张独立贴图的原因。如果六张图各自为政导入引擎后光照反应会很假凹凸和颜色也对不上。正确思路是以高度图作为核心信息源其余所有通道从它派生这样所有数据在空间上严格对齐。后续我还拓展了金属边缘磨损生成、污渍蒙版、AO 烘焙等功能。比如 AO 通道可以简单地通过高度图的分层阈值来表达凹陷区域叠加多层黑暗遮罩模拟环境光遮蔽。这些扩展保持在一个统一的框架内代码量不大带来的画面提升却非常明显。5.2 我在实际使用中的几个体会artcraft 跑到现在我最想分享的不是技术而是几个心态上的体会。程序化生成这套东西入门时最容易犯的错误就是沉迷参数。我曾经为了调一个 warp 参数来回生成了一百多张图最后发现调它还不如直接改 seed。后来我给自己定规矩想改风格先改频率其次改种子最后才调 warp 这类修饰参数优先级固化后效率高很多。另一个体会是程序化纹理的制作过程看得到结果但更值得记录的是路径。节点图的结构、关键参数的解释、为什么这么设置这些文字积累下来比一百张贴图本身更有价值。你回头看自己的项目能说出来当时为什么选这个方案比能再复现一张纹理重要得多。还有一点特别想说程序化生成不意味着放弃审美。它只是把笔刷换成了代码把画布换成了参数空间。判断一张程序化纹理好不好看的直觉和做手绘时完全一样。我见过一些人拿到工具后一味堆叠噪声层数认为细节越多越好结果整个画面像被腐蚀过一样。审美上的克制在这套流程里同样关键。artcraft 这个项目给我最大的收获不是产出多少纹理资产而是它让我看到手艺可以被结构化艺术可以被参数化而这两者之间其实有一条非常务实的路。