ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

用代码织出自然纹理:artcraft如何将手工质感转化为算法逻辑

用代码织出自然纹理:artcraft如何将手工质感转化为算法逻辑

1. 项目概述:artcraft 到底在做什么

artcraft 这个名字听起来很直接——art 加 craft,艺术加工艺。但真正上手做这个项目之后,我才意识到这两个词合成在一起的分量:它不是一个单纯的生成艺术工具,也不是一个传统手工艺的数字化文档,而是一整套把"手工质感"转化为"可计算逻辑"的创作框架。

我最初做 artcraft,是因为一个很实际的痛点。工作室接到过一批纹理设计需求,客户要的是那种看起来像手工编织、自然釉裂、老木纹的视觉素材。用布景拍照的话,一组纹理要拍好几个小时,而且可控性很差——光线、角度、材料批次都会影响效果。用现成的纹理库呢,又容易被别人复用,版权上也有隐患。当时我们就在想,能不能用代码直接"织"出这些纹理,让每一次生成都带着手工艺的随机性和温度,同时又完全归我们控制。

artcraft 的定位就这样定下来了:一个面向设计师和创意开发者的生成纹理与图案创作工具包。它把传统工艺中的核心生成规则抽象出来——比如编织的经纬交错、陶瓷开片的裂纹扩散、木纹的年轮生长——然后用参数化和随机控制的方式重新实现。跑出来的东西不是简单的 2D 贴图,而是带着自然瑕疵、有机走向、甚至某种"拙感"的视觉资产。

这个项目比较适合三类人:第一类是平面设计师,想快速生成可商用的原创纹理素材;第二类是创意编程爱好者,想理解自然纹理背后的算法原理;第三类是前端开发者,需要在一套轻量级代码里实现复杂的图案生成逻辑。不夸张地说,只要你能想清楚一种工艺的规则,你就能用 artcraft 的思路把它变成代码。

2. 整体设计与思路拆解:为什么这样构建

2.1 设计哲学:受控随机与工艺规则优先

先说一个我在实际开发中反复体会到的道理:生成艺术最大的陷阱不是"太随机",而是"随机太容易了"。很多人写生成代码,第一步就是Math.random()到处撒。跑出来一看,确实什么都有,但什么都不可用——因为它没有工艺规则约束。

artcraft 的核心哲学是"受控随机"。拿编织纹理举例,真实的一块手工粗布,经纬线之间确实有误差——有的地方密一点、有的地方松一点、偶尔有一根线走偏了——但整体上它仍然遵循"一上一下交错"的基本规则。如果完全随机,你得到的就是一坨马赛克;只有把随机限制在"经线间距 ±5%"、"纬线张力 ± 2%"这样的范围内,才能织出那种既规整又有手工感的纹理。

所以 artcraft 里所有的生成器都遵循同一个模板:工艺规则定义整体结构,随机参数定义局部扰动,规则永远优先于随机。这种设计哲学带来的好处是,输出结果始终在"可用"的范围内波动,而不是在"失控"的边缘试探。你调整参数时,纹理在变,但不会突然变得不像它本身。

2.2 技术选型与工具链解析

artcraft 底层用的是 JavaScript,渲染层选的是 Canvas 2D API,输出层支持 SVG 和 PNG 序列。好多朋友问我为什么不用 WebGL,毕竟 WebGL 性能更强、看起来更"高级"。

我的理由其实很简单:这个项目的主要输出是 2D 平面纹理和图案,单张画布的分辨率撑死也就 4096×4096,这种量级 Canvas 2D 完全扛得住。而且 Canvas 2D 的 API 非常直观——ctx.beginPath()、ctx.moveTo()、ctx.lineTo()、ctx.fill()——每一步都跟真实的绘制行为对应,尤其在模拟手工笔触、线迹这一类操作时,这种"一笔一笔画出来"的心智模型特别顺手。引入 WebGL 意味着要管理着色器、缓冲区、绘制管线,对纹理生成这个场景来说属于杀鸡用牛刀,还把代码复杂度拉高了两个量级。

在同一套工具链里,SVG 承担了矢量输出。Canvas 负责屏幕上实时预览,SVG 负责交付给印刷或矢量编辑软件,两者通过一套统一的参数配置驱动。这个组合看起来朴素,但稳定、易调试、可爬坡——任何人都能在半小时内看懂代码怎么跑起来的。

2.3 从手工到代码:三类典型工艺的数字化逻辑

艺术创作和手工艺之间有一道很微妙的鸿沟。艺术家关心的是"我想表达什么",传统匠人关心的是"我的手要怎么做",而程序员关心的是"机器的指令怎么下"。artcraft 要做的就是在这三者之间搭一座桥。

以编织为例。真实的织物由经线和纬线构成,二者的交叉方式决定了织物组织。平纹组织就是一上一下,斜纹组织是二上一下递移。这个规律非常结构化,非常适合编码。但一条真实的经线是有"毛边"的——它不完全是一条均匀的直线,粗细会变化,张力会导致局部弯曲。所以在代码里,我会先生成规则的网格交叉结构,再在每条线的路径上叠加噪声偏移,最后对线迹宽度做随机微调。这样数字化的结构保留着编织的秩序,噪声则补上了手工的不完美。

陶瓷开片又是另一套逻辑。釉面裂纹本质上是一个"应力释放"过程:釉层在冷却时收缩比胎体大,于是产生裂纹。从算法角度去看,这近似于 Voronoi 图的边界——一组种子点各自扩张到相邻区域,边界就是裂缝。真实的裂纹不是一条干净的分界线,它会在扩张过程中产生分岔,边沿有细小的崩碎。所以代码实现上需要用噪声扰动种子的扩张速率,再让裂缝边界产生次级分支。

木纹就更典型了。树木年轮的生成是一个"同心圆 + 生长扰动"的过程。树不是正圆的,受养分、风向、光照影响,每一圈都不同。年轮之间的间距有随机性,早材晚材颜色深浅也不同。编码时我先用极坐标生成同心圆,再给每个圆的半径加上低频噪声,让"圆"变得不规则,最后对环带颜色做渐变填充。

这三类案例其实说明了一件事:所有工艺纹理的背后都有"结构规则 + 随机扰动"双重逻辑。代码的作用不是替代手艺,而是把这两层逻辑变成可调节的参数。

3. 核心实现拆解与实操要点

3.1 随机种子:让"随机"可复现

很多人第一次用 artcraft 会感慨"每次生成都不一样"。这个特性用来找灵感确实不错,但设计交付的时候就是灾难——客户看完说"这个纹理 OK",结果你刷新一下页面,纹理变了。你怎么交付?所以随机种子系统是在项目第 1 版就必须落地的功能。

方案就是实现一个可复现的伪随机数生成器。我推荐 mulberry32,实现很短但随机分布质量足够好:

function mulberry32(seed) { return function() { seed += 0x6D2B79F5; let t = seed; t = Math.imul(t ^ t >>> 15, t | 1); t ^= t + Math.imul(t ^ t >>> 7, t | 61); return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }

使用方式非常直白:生成器接收一个整数 seed,返回一个无参随机函数,每次调用返回 [0, 1) 区间的值。你看它内部只依赖 seed 的数值和位运算,所以只要是同一个 seed,不管在哪个机器上跑、跑多少次,得到的随机序列都是一模一样的。这个特性在生成艺术里是刚需——你输出给印刷厂之前,可以锁定一个 seed 反复微调其他参数,最终交付时记录{ seed: 20241101, size: 300, density: 0.8 },对方就能在任意环境里精确复现同一张纹理。

实战中有一个容易忽略的细节:如果用Math.random()来生成"给随机函数的种子",那么每次刷新还是会不一样。正确做法是把 seed 暴露成一个独立参数,由用户在界面上手动输入或点击"随机一个"。我习惯的做法是页面加载时默认从一个可预测的地方取值,比如当天日期,这样打开页面时大家看到的是同一幅基础纹理,点"随机"按钮才更新 seed。

3.2 噪声函数:自然纹理的基石

要让纹理"自然",不能靠纯随机。真实的自然界——不管是木头的纹理、石材的颗粒还是织物的起伏——都有一个特征:相近位置的取值高度相关,远处取值才逐渐无关。这就是噪声函数的用武之地。

最常用的是 Value Noise,原理比 Perlin Noise 简单却足够好用。我在 artcraft 里封装过一个极简版本,大概就是"在整数网格顶点上放随机值,中间用平滑插值填充":

function valueNoise2D(x, y, rand) { const x0 = Math.floor(x), y0 = Math.floor(y); const x1 = x0 + 1, y1 = y0 + 1; const xf = smoothstep(x - x0); const yf = smoothstep(y - y0); const v00 = rand(x0, y0), v01 = rand(x0, y1); const v10 = rand(x1, y0), v11 = rand(x1, y1); return lerp(lerp(v00, v10, xf), lerp(v01, v11, xf), yf); }

注意这里的rand(x0, y0)必须是"根据整数坐标返回固定随机值"的函数,可以用整数坐标混合后作为种子再调用 mulberry32。smoothstep是典型的缓动函数,可以用t*t*(3-2*t)实现,它保证了网格点之间过渡连续、没有折痕。

Value Noise 在 artcraft 里的使用场景非常多:木纹年轮半径的扰动、编织线的走向偏移、釉面裂纹的分岔方向。你只需要把噪声频率和幅度做成可调节的参数,就能从"均匀的规则图案"平滑过渡到"扭曲的自然形态"。频率控制细节密度,幅度控制扭曲强度。这是纹理生成的万金油。

3.3 游走画笔:手工笔触的模拟

canvas 里画一条线太简单了,但画一条像手工绘制、带着呼吸感、有起笔收笔轻重变化的线,需要专门实现。我在 artcraft 里管这个叫"游走画笔"(walker brush)。

核心原理是模拟一支笔在画面上移动的状态。笔有自己的位置 (x, y)、速度 (vx, vy)、压力 (pressure)。每帧更新时,速度方向被一个随机转向扰动,压力则用一个低频率噪声调制。然后把压力映射到线的宽度,位置加上一些小幅度摆动:

function walkerStep(w, rand, noise) { const angle = Math.atan2(w.vy, w.vx) + (rand() - 0.5) * w.turnFactor; const speed = w.speed * (0.9 + rand() * 0.2); const nextX = w.x + Math.cos(angle) * speed; const nextY = w.y + Math.sin(angle) * speed; const pressure = 0.5 + 0.5 * noise(w.x * 0.02, w.y * 0.02); return { x: nextX, y: nextY, width: w.baseWidth * pressure }; }

这种实现有个好处:它模拟出的线条不会像 CAD 那样机械均匀,而是呈现一种"笔尖在纸面上轻微游移"的质感。实际操作中,turnFactor是最关键的参数,取值低时线条接近直线,取值高时线条像酗酒的人画的。用来做编织线迹时,我一般把 turnFactor 控制在 0.1~0.3 之间,刚好能打破直线感又保持整体的走势。

3.4 色彩调和系统

很多生成工具翻车就翻在色彩上。算法跑得挺顺,结构也对,但颜色一出就是一股"廉价感"。调研之后发现,问题几乎都出在色彩饱和度太高且色相毫无关联上。

artcraft 的解决方案是引入"色彩调和系统"。它不是一个简单的取色器,而是一套基于前期"工艺色板"的约束规则。每个色板定义了 3~6 个基础色,每个基础色派生出一系列的明度变体。生成纹理时,颜色不能直接从 RGB 空间乱跳,而是通过 HSL 空间在基准色附近小范围偏移,偏移量同样由随机种子控制。

一个可复制的经验是:把色板里的颜色饱和度控制在 20%~45% 之间,明度控制在 30%~85% 之间。这区间里的颜色不容易刺眼,也不至于灰到没精神。艺术感往往就来自这些"不怎么鲜艳"的颜色之间的微妙关系。如果你想要更稳的方案,可以直接从传统织物或陶瓷的实物色卡上取色,然后把这些颜色的 HSL 值输进色板。大自然和传统工艺早就帮你调好了不会出错的组合。

3.5 参数面板与导出链路

artcraft 的界面层用的是 lil-gui,一个轻量级参数面板库。我特意选它有几点考虑:零依赖、体积小、配置代码直观,而且它天然支持把参数对象序列化成 JSON。这个特性对生成工具尤其重要,因为它让你可以把一整组调节好的参数存成一个 preset,下次导入即可精确恢复同样效果。

参数面板里的每一项都会在每次变更时触发重绘,但为了避免拖动滑块时卡顿,我用了一个 200ms 的防抖函数。如果你自己实现,这行代码能解决很大的体验问题:

let timer; function scheduleRedraw() { clearTimeout(timer); timer = setTimeout(draw, 200); }

导出链路的设计上,SVG 由一个独立的生成函数负责。Canvas 画一遍全图,SVG 会重新遍历同样的绘制指令,一一映射到 SVG 元素。这个设计有一个好处——最终的矢量文件保留了每一条线段、每一个节点的信息,可以后续在矢量编辑软件里手动调整。为此,代码模型上我抽象了一个中间指令列表,Canvas 和 SVG 分别通过各自的渲染器解释这份列表。避免"Canvas 一套代码、SVG 另一套代码"的双维护。

4. 实操案例:三个可以直接跑起来的示例

4.1 经纬编织纹理生成

这是 artcraft 里我最常用来做演示的案例,因为编织纹理的生成规则足够简单,效果却很容易出彩。核心分三块:经线组、纬线组、交织效果。

首先定义编织密度。假设画布 800×800,经纬各 40 条,那么每条线的基准间距是 20 像素。为了让线距有手工感,我在基准间距上乘以(1 + (rand() - 0.5) * 0.1),让每条线的间距在 19~21 像素波动。线的路径不是直线,而是沿水平方向走时叠加低频 noise 扰动,幅度约 2 像素,形成轻微的弯曲。

交叉效果是编织纹理的灵魂。我采用一个双重循环:绘制每一条纬线时,检查它与经线的交点,根据坐标的奇偶规律决定"浮在经线上"还是"沉在经线下方"。沉下去的部分,线的颜色加深且宽度略微缩小,模拟被压在下面的阴影感。浮在上方的部分则保持原色,并多绘制一两像素的高光。这样跑出来,交错的立体感就出来了。

关键参数就三个:density(编织密度)、irregularity(间距不规则度)、curveStrength(弯曲强度)。我实测下来,最常用的组合是密度 40、不规则度 0.08、弯曲强度 2,出来的效果非常像手工粗织棉布。

4.2 陶瓷开片裂纹纹理

陶瓷开片的难点在裂纹的形态——它不是几条简单的线段,而是一组互相连通、带分岔的网状结构。artcraft 里用的是 Voronoi 思路。

简化的做法是不做完整的 Voronoi 划分,而是先在画布上随机撒 20~30 个点作为"裂纹源",然后让裂纹从每个源点出发,以随机方向逐段生长。每一段都记录方向和长度,当某条裂纹快要撞上另一条时,它会偏向并与之接合,形成交叉点。这个"避让-接合"逻辑能产生自然的分岔效果。

为了让裂纹更真实,我给每段裂痕添加了边沿细节:裂痕不是一条单线,而是主缝加两侧随机暗色细线,模拟瓷釉崩碎的毛边。背景色我用浅青灰色,印象里青瓷开片就是那种暧昧的青白色,裂纹色则用深褐灰色。说真的,代码跑出来的开片纹理印在亚克力板上,效果好到客户以为我们请了景德镇的师傅画了三天。

需要留神的一点是:这种生长算法是有while循环的,如果参数设置不当——比如步长太小、步数太多——会陷入性能泥潭。我的处理办法是每一步都检查当前总节点数,超过 10000 个就强制停止,保证任何输入都能在几百毫秒内出图。

4.3 木纹年轮生成

木纹是所有纹理案例里代码量最少、但"像不像"最容易出问题的一个。很多初版木纹看起来像靶纸,就是因为年轮被画成了完美的同心圆。

正确的做法是先定义一个极坐标系下的"基准半径",年轮 i 的半径是r = i * ringWidth。然后把每个半径通过 noise 函数扰动,扰动随角度变化但不随半径变化,这样整棵树的年轮会呈现统一的"歪斜感"——表现的是树在生长过程中受到的偏压。再多加一个小振幅的高频噪声,让年轮边界产生局部的粗糙。

木纹的颜色渐变也要讲究。靠近髓心的部分一般是浅色的早材,靠近外侧的晚材颜色更深。我在相邻年轮之间做渐变,深色晚材宽度约占环宽的 15% 到 25%,这个比例直接影响仿真度。最后在背景上叠一层几乎看不出的径向光晕,让木纹表面有轻微的反光立体感。

这类纹理在商业里最实用——壁纸、地板设计、产品包装的木质质感模拟,全是刚需。生成一版不需要拍照设备,参数可控且无版权风险。

5. 常见问题与排错实录

5.1 同一份种子为什么两次结果不一致

这是新手最常遇到也最容易措手不及的问题。明明在同一次会话里点击了保存 preset,第二天打开回来用同一个 seed 恢复,结果图却不一样了。

第一个排查对象是Math.random()。很多人在局部代码里图方便直接用了它,比如决定色相偏移量时写Math.random() * hueRange。这份子随机序列不归"种子系统"管,所以每次运行都会变化。解决办法就是全局禁用Math.random(),所有随机值一律通过种子随机函数产生。

第二个排查对象是对象的遍历顺序。JavaScript 的对象键在特定情况下会重新排序,尤其是整型键。如果你用for...in或Object.entries()遍历参数对象并依次消耗随机数,那么随机数的消耗顺序在不同环境下可能不一致,导致生成结果漂移。解决方法是把参数对象转换成一个有序的数组,严格按数组顺序消耗随机数。

5.2 Canvas 导出 SVG 图形错位

Canvas 和 SVG 明明用的同一份指令列表,但导出文件就是和屏幕预览错位。这个问题我排查了很久,最后发现是坐标系统的差异。

Canvas 的坐标原点是画布左上角,而 SVG 的默认坐标体系也是左上角,单看原点没问题。但实际预览时为了让纹理居中加入了很多位移和缩放变换——比如ctx.scale(2, 2)、ctx.translate(40, 40)——而导出时这些变换没有重新计算到绝对坐标里。正确的做法是在导出 SVG 之前,把指令列表里的每个坐标点先做逆变换,或者生成时就记录"世界坐标"和"屏幕坐标"两套值,导出用世界坐标,预览用屏幕坐标。

还有一个更隐蔽的坑:Canvas 的线宽会被变换矩阵 scale 影响,你预览时线宽 2 在 scale 2 下实际是 4。导出 SVG 时如果用原始线宽,就会出现"预览粗、导出细"的差异。要确保导出的stroke-width等于 屏幕线宽除以整体缩放量。

5.3 渲染性能突然暴跌

画布就 800×800,操作滑块却卡到 3 帧每秒。遇到这种情况,不要急着怀疑 Canvas 2D 的性能,先查你的代码里有没有"无意中创建的重复对象"。

我踩过最大的性能坑是:把一副纹理的生成函数写成了每帧都重新计算全部噪声。木纹案例里,噪声计算是每帧全量重跑。后来我给这功能加了一个缓存层——输入参数不变时,直接用cacheKey = seed + density + irregularity做键,把生成结果存进 Map,只有参数真正变化时才重算。加了这层之后大部分滑块操作都能流畅跟手。

另外要检查requestAnimationFrame是否和参数防抖冲突了。如果每次参数变化都同时触发一次setTimeout重绘和一次requestAnimationFrame,会产生重复绘制。我最后的统一方案是:所有重绘请求都带一个递增的 revision 号,重绘前检查 revision 是不是最新,过期请求直接丢掉。

5.4 色彩输出偏灰偏脏

生成结果结构没毛病,但颜色总像蒙了一层灰。排查方向有两个。

一是颜色运算过程中是不是无意中把 RGB 分量截断了多次。每截断一次,色域边缘的细节就丢失一点,累积起来画面自然就脏了。建议在调色统一在 HSL 空间做,只在渲染最后一步转成 RGB。

二是色板里的颜色明度反差太小。所有颜色都挤在中明度区间,画面缺少跳动的亮点和收得住的重色。调整办法是在色板里保证存在一个明度低于 40% 的深色和一个明度高于 75% 的浅色,中间色作为过渡。这个技巧对任何层次的纹理都有效,不只是 artcraft。

6. 扩展思路与个人体会

6.1 从 2D 纹理到 3D 表面

artcraft 做到后面,已经不满足于平面纹理了。我们把生成的灰度图导出成 bump 贴图或 normal 贴图,直接塞进三维渲染软件里做表面凹凸。编织纹理生成器输出的经纬交叉高度信息,取灰度后映射到模型的布料表面;陶瓷裂纹的线条在渲染器里通过透明度通道变成了真实的裂纹凹槽。

这个玩法之所以可行,是因为 artcraft 的生成过程始终保持了"结构信息"。它不是像截图一样拍一张纹理,而是保留了每一条线、每一个节点的空间关系和属性。把线宽换成模型采样用的权重,把颜色深浅换成高度值,一张 2D 纹理就变成了 3D 表面的 displacement 数据。这让 artcraft 从一个平面素材工具,变成了跨维度的材质生成器。

6.2 我在维护过程中积累的几点经验

一个开源工具项目能做起来,技术只是基础,更多是靠使用场景的持续验证。我自己最常做的事,是把每次案例积累下来的"好参数"存成预设,并且在预设文件里写好注释,记录当时是怎么调出来的。这个习惯帮了大忙——三个月后再回来看这个项目,不用重新摸索。

另一个很实际的经验是:一个生成工具的交互界面要"减少抽象层次"。用户不关心你内部用的是 Value Noise 还是 Perlin Noise,他们只关心"密度""粗细""随机程度"这种直观指标。把复杂参数命名成普通人能理解的自然语言,工具才真正好上手。

最后一点,也是在维护 artcraft 的过程中被反复验证的:任何工艺纹理的数字化,都包含"结构提取"和"瑕疵注入"两个阶段。结构提取解决"像不像",瑕疵注入解决"真不真"。这两个阶段必须清晰地分开设计,一旦混在一起,参数就会乱掉,纹理也会失去秩序感。这条原则不仅仅适用于纹理生成,我觉得放在任何创作类工具的设计里,都是一个有效的指导框架。

RELATED READING

延伸阅读

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