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 | 值必须是整数,否则会出现接缝 |
| octaves | FBM 叠加层数 | 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, dtype=float) 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, lacunarity=2.0, gain=0.5, seed=0): total = np.zeros(shape, dtype=float) 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), octaves=5, lacunarity=2.0, gain=0.5, seed=2024) 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, strength=2.0): h, w = height_map.shape normal = np.zeros((h, w, 3), dtype=float) # 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), octaves=4, seed=7) roughness = np.clip(roughness, 0.05, 1.0)这行代码做完之后,整张贴图的基本材质要素就齐了。另外为了生成 basecolor,我又在高度图基础上做了一次色调映射:高度低处偏暗褐色,高度高处偏亮灰色,然后叠一层低强度的 Worley 噪声来模拟岩石颗粒。
basecolor = np.zeros((*shape, 3), dtype=float) 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), seed=11)[:, :, 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 这个项目给我最大的收获,不是产出多少纹理资产,而是它让我看到:手艺可以被结构化,艺术可以被参数化,而这两者之间其实有一条非常务实的路。