
1. 从“青绿”到“行空”一个数字艺术项目的诞生最近在整理硬盘翻到了一个去年做的个人项目当时纯粹是出于兴趣想试试看能不能用代码把中国传统山水画里的那种意境给“算”出来。项目标题叫“千里江山入行空只此为青绿”听起来有点玄乎其实核心就是想用程序化的方式探索《千里江山图》那种青绿山水在数字空间里的无限延伸感。这不像是一个标准的工具教程更像是一次创作实验的记录聊聊我怎么从一个想法到一堆代码最后折腾出一些能动的、有“古意”的现代图像。“青绿”二字直接指向了以北宋王希孟《千里江山图》为代表的青绿山水画派。这种画风用石青、石绿等矿物颜料色彩浓烈绚烂但又通过精妙的构图和渲染营造出千里江山的壮阔与深邃是一种高度程式化又充满想象力的艺术语言。而“行空”和“入行空”则是我个人想赋予这个项目的数字属性——让它不再局限于静态的绢本而是能在虚拟的、无限的行可以理解为行列、路径也可以是运行与空中生长、变幻。所以这个项目的本质是尝试用算法和生成艺术Generative Art的思路去解构和重构“青绿山水”的视觉元素与空间逻辑。它不是要做一个高保真的《千里江山图》复制品那没有意义也远非个人能力所及。我的目标是捕捉其“神韵”的几个关键点层叠的、带有几何感的山形标志性的青绿色彩体系以及那种“可居可游”的、看似随机实则有序的构图节奏。然后用代码让这些元素在一定的规则下自动组合、衍生看看能碰撞出什么意想不到的“数字山水”。整个过程没有使用任何现成的山水画生成模型而是从更基础的噪声函数、色彩映射和几何变形入手。工具链也很简单主要依赖Processing或者用p5.js在网页端实现也可以这是一个非常适合创意编码和视觉艺术的原型工具。下面我就把这趟有点“折腾”但也充满乐趣的创作之旅拆解开来如果你也对用代码画画、探索传统艺术的数字转译感兴趣或许能给你一些启发或避坑的思路。2. 解构“青绿”视觉元素与算法映射动手写代码之前得先想明白我们要让计算机“理解”并生成什么样的画面。对着《千里江山图》看了很久我尝试提炼出几个可以用算法来模拟的核心特征。2.1 山形的几何与层叠逻辑传统山水画的山不是对真实地貌的写实而是经过高度提炼的符号。在《千里江山图》中山体形态有几个特点轮廓的几何感山脊线往往由一系列短促、有力、带有顿挫的线条构成整体轮廓接近梯形、三角形或其变体而非平滑曲线。明确的层叠关系前景、中景、远景的山层层推远通过遮挡和大小变化表现空间。这种层叠不是简单的复制粘贴而是有节奏的渐变。皴法的纹理暗示山体表面通过不同的皴法如披麻皴、斧劈皴来表现质感和体积。虽然我们无法用代码完美复现毛笔皴擦的韵味但可以用纹理来模拟那种粗糙、有方向的肌理感。在代码中的实现思路我选择用多层、变形的多边形来构建山形。每一层山体本质上是一个由顶点序列构成的多边形。如何生成这个顶点序列呢这里就用到了噪声Noise函数特别是Perlin Noise或Simplex Noise。它们能生成平滑、连续的随机值非常适合模拟自然形态。具体步骤是确定山体的基线山脚线和大致高度。沿着水平方向以固定间隔采样用噪声函数生成每个采样点垂直方向的偏移量。这个偏移量决定了山体轮廓的起伏。通过调整噪声的“频率”和“振幅”可以控制起伏的细碎程度和剧烈程度。将所有这些采样点连接起来再闭合到基线上就形成了一个基本的山形多边形。// 伪代码示例生成一个山形轮廓的点数组 ArrayListPVector generateMountainShape(float baseY, float width, float height) { ArrayListPVector points new ArrayListPVector(); float noiseScale 0.02; // 噪声尺度控制起伏频率 float noiseDetail 4; // 噪声细节 float timeOffset frameCount * 0.01; // 加入时间偏移可以实现动画 // 从左到右生成顶点 for (float x 0; x width; x 5) { // 5是采样步长 // 核心用噪声计算y轴偏移。noise(x, y, z)返回0-1的值 float noiseValue noise(x * noiseScale, timeOffset); // 将噪声值映射到高度范围内产生起伏 float yOffset map(noiseValue, 0, 1, -height * 0.3, height * 0.3); float y baseY - height yOffset; // baseY是画布底部向上减高度 points.add(new PVector(x, y)); } // 添加多边形底部左右两个点使其闭合 points.add(new PVector(width, baseY)); points.add(new PVector(0, baseY)); return points; }通过生成多个这样的多边形并让它们的基线逐渐升高模拟远景、宽度和高度逐渐减小、颜色逐渐变淡变冷就能初步营造出层峦叠嶂的空间感。这里的一个小心得是远景山的噪声频率可以调高一些更细碎振幅调低一些起伏更平缓这样符合空气透视原理。2.2 青绿色彩体系的数字化青绿山水的色彩是其灵魂。石青和石绿并非单一的纯色而是有深浅、冷暖、鲜灰的丰富变化。山体阳面可能用暖绿阴面用冷青山顶施以石青山腰着石绿还有赭石、朱砂等颜色作为点缀。在代码中的实现思路直接定义几个固定色值是不够的会显得生硬。我们需要一个可以灵活生成一系列青绿色调的方案。我采用了HSB/HSL 色彩空间来代替常用的 RGB。因为 HSB色相 Hue、饱和度 Saturation、亮度 Brightness更符合我们对色彩“种类”、“鲜艳度”、“明暗”的直观感知。定义主色相范围青Cyan的色相大约在 180° 左右绿Green在 120° 左右。我们可以设定一个主色相比如 160°然后在其附近例如 ±20°进行随机或噪声扰动这样产生的颜色都在青-绿谱系内又富有变化。控制饱和度与明度饱和度不宜一直最高否则会刺眼。可以结合山体的层叠远景饱和度降低或利用噪声来微调。明度则可以用来表现受光亮与背光暗面这是塑造立体感的关键。色彩分层渲染不要用一个颜色填满整个多边形。我尝试了两种方法线性渐变从多边形顶部的较亮、较冷的青色到底部的较暗、较暖的绿色。噪声纹理叠加在填充基本色后再用一层低透明度、基于噪声生成的纹理色可能是偏赭石、偏灰的色调进行叠加模拟矿物颜料不均匀的颗粒感和岁月感。// 伪代码示例基于层深和噪声生成青绿色 color generateGreenBlueColor(int layer, float x, float y) { // layer: 0为前景数值越大越远 float depthFactor map(layer, 0, maxLayers, 1.0, 0.6); // 远景色彩强度减弱 // 基础色相在青绿色范围 float baseHue 160; // 用噪声添加细微的色相变化使颜色不死板 float hueVariation map(noise(x*0.01, y*0.01), 0, 1, -10, 10); float hue (baseHue hueVariation) % 360; // 饱和度远景降低同时加入一些随机变化 float saturation 70 * depthFactor map(noise(x*0.005, y*0.005), 0, 1, -10, 10); saturation constrain(saturation, 50, 80); // 明度同样受景深影响并可用另一组噪声模拟光影 float brightnessNoise noise(x*0.02, y*0.02 1000); // 加偏移量避免与色相噪声同频 float brightness 60 * depthFactor map(brightnessNoise, 0, 1, -15, 15); brightness constrain(brightness, 30, 75); return color(hue, saturation, brightness); }注意HSB 色彩空间在不同设备和库中的具体表现可能有细微差异。在 Processing 中colorMode(HSB, 360, 100, 100)将色相范围设为 0-360饱和度和亮度为 0-100这样最直观。如果移植到其他环境需要查证其 HSB 模型的具体参数范围。2.3 构图与空间的“算法意境”中国画的构图讲究“经营位置”散点透视使得画面可以容纳“千里”之景。在算法生成中我们无法主观“经营”但可以设定规则来模拟那种有序的随机感。我的策略是分形与迭代。从一个简单的山形单元开始应用一套规则缩放、旋转、位移、颜色变化来生成它的“子代”山体子代山体再生成“孙代”如此迭代几次就能形成一个看似复杂、但有内在统一性的山群。这有点像分形几何但规则更自由更偏向艺术化。前景锚定首先在画布底部靠前的位置生成几个形态较为完整、细节丰富噪声频率高、颜色浓郁的山体作为前景。中景衍生以前景山体的顶部或轮廓线为“种子”在其后方y坐标更小和侧方x坐标偏移生成缩小后的新山体。缩小的比例、偏移的距离都可以引入随机性但要控制在一定范围避免混乱。远景弥漫继续上述过程山体越来越小颜色越来越淡、越冷轮廓也越来越简化降低噪声细节。最终它们可能融汇成一片青绿色的、朦胧的远山背景。留白与气韵中国画讲究“计白当黑”。在代码中要有意识地“不画”。确保画布上有足够的“空”的区域可以是渐变的天空也可以是纯粹的留白不要让生成的山体挤满画面。可以通过控制迭代生成的概率或者设置一个生成边界来实现。这个过程充满了实验性。参数如缩放因子、偏移范围、生成概率需要反复调整才能得到疏密有致、既有节奏又不呆板的效果。我经常一调就是几个小时生成几百张图才能找到一组感觉还不错的参数组合。这可能是生成艺术创作中最耗时也最有趣的部分——与机器协同“探索”视觉的可能性。3. 核心实现噪声、迭代与动态渲染有了前面的设计思路我们就可以进入具体的代码实现环节了。我将整个生成系统分为几个模块方便管理和调试。3.1 山体对象Mountain Class的设计首先我们需要一个类来封装一座“山”的所有属性。这是面向对象编程的基本操作能让代码清晰很多。class Mountain { PVector position; // 山体基线的参考位置通常是左下角 float width; // 山体宽度 float height; // 山体高度 int layer; // 所属层0前景越大越远 ArrayListPVector vertices; // 山体轮廓的顶点数组 color fillColor; // 填充色 color strokeColor; // 轮廓色有时可以不用 float noiseSeed; // 专属的噪声种子确保每座山形态唯一 Mountain(float x, float y, float w, float h, int l, float seed) { position new PVector(x, y); width w; height h; layer l; noiseSeed seed; vertices new ArrayListPVector(); generateVertices(); calculateColor(); } void generateVertices() { vertices.clear(); float noiseFreq map(layer, 0, 5, 0.03, 0.01); // 远景噪声更平滑 float noiseAmp map(layer, 0, 5, height * 0.4, height * 0.2); // 远景起伏更小 for (float px 0; px width; px 4) { // 步长影响轮廓精细度 // 关键为每座山使用独立的噪声种子避免所有山轮廓雷同 float n noise(px * noiseFreq noiseSeed, noiseSeed * 100); float yOffset map(n, 0, 1, -noiseAmp, noiseAmp); float vx position.x px; float vy position.y - height yOffset; // 注意Y轴方向向上为负 vertices.add(new PVector(vx, vy)); } // 添加底部点闭合多边形 vertices.add(new PVector(position.x width, position.y)); vertices.add(new PVector(position.x, position.y)); } void calculateColor() { // 调用前面定义的色彩生成函数传入山体中心点坐标 float centerX position.x width / 2; float centerY position.y - height / 2; fillColor generateGreenBlueColor(layer, centerX, centerY); // 轮廓色可以设为填充色的深色版本或直接透明 strokeColor color(hue(fillColor), saturation(fillColor), brightness(fillColor) * 0.7); } void display() { fill(fillColor); stroke(strokeColor); strokeWeight(1); beginShape(); for (PVector v : vertices) { vertex(v.x, v.y); } endShape(CLOSE); } }这个类把山的生成、着色和绘制都打包好了。noiseSeed非常重要它确保了即使使用相同的参数每座山因为起始的噪声值不同也会拥有独一无二的轮廓。3.2 迭代生成系统让山峦“生长”接下来我们需要一个管理器来执行前面提到的分形迭代逻辑创建和管理一群Mountain对象。ArrayListMountain mountains; // 存储所有山体的列表 void setup() { size(1200, 800); colorMode(HSB, 360, 100, 100, 100); noLoop(); // 初始静态生成后面可以改成动态 mountains new ArrayListMountain(); generateMountainRange(); } void generateMountainRange() { mountains.clear(); // 1. 生成前景“母山” int foregroundCount 3; for (int i 0; i foregroundCount; i) { float x map(i, 0, foregroundCount, width * 0.1, width * 0.9); // 横向分布 float y height * 0.7; // 基线在画布偏下位置 float w random(150, 250); float h random(200, 300); float seed random(1000); // 随机种子 Mountain m new Mountain(x, y, w, h, 0, seed); mountains.add(m); } // 2. 迭代生成中景和远景 int totalLayers 5; // 总共5层含前景 for (int currentLayer 0; currentLayer totalLayers - 1; currentLayer) { // 找出当前层的所有山 ArrayListMountain layerMountains new ArrayListMountain(); for (Mountain m : mountains) { if (m.layer currentLayer) { layerMountains.add(m); } } // 为当前层的每座山生成“子山” for (Mountain parent : layerMountains) { int childrenPerParent int(random(1, 4)); // 每座母山生成1-3个子山 for (int c 0; c childrenPerParent; c) { // 子山的位置基于母山顶部附近 float childX parent.position.x random(-parent.width*0.2, parent.width*0.8); float childY parent.position.y - parent.height random(-20, 20); // 在母山顶部附近 // 子山更小、更远 float scaleFactor random(0.5, 0.8); // 缩放因子 float childW parent.width * scaleFactor; float childH parent.height * scaleFactor * random(0.9, 1.1); // 高度略有变化 // 确保子山不会跑到画布外或太靠下 childX constrain(childX, width * 0.05, width * 0.95 - childW); childY constrain(childY, height * 0.1, height * 0.6); // 基线逐渐上移 int childLayer currentLayer 1; float childSeed parent.noiseSeed c 1; // 子山种子与母山关联但不同 Mountain child new Mountain(childX, childY, childW, childH, childLayer, childSeed); mountains.add(child); } } } } void draw() { background(220, 30, 95); // 一个非常浅的灰青色背景模拟天空或留白 // 按层数从远到近绘制确保正确的遮挡关系远景先画 for (int l 4; l 0; l--) { for (Mountain m : mountains) { if (m.layer l) { m.display(); } } } }这个生成系统是项目的核心引擎。scaleFactor缩放因子、childrenPerParent子山数量、位置偏移的随机范围等参数直接决定了最终构图的疏密、节奏和气势。你需要像调整乐器一样反复调试它们。3.3 从静态到动态“入行空”的动画尝试静态图像固然美但“入行空”的标题让我想尝试加入动态元素让这片数字山水“活”起来。Processing 的draw()函数每秒运行很多次这为我们提供了实现动画的基础。动态化的思路主要有两个方向云雾缭绕在山水间叠加一层半透明的、缓慢运动的噪声纹理模拟云雾的流动。这可以通过在每一帧绘制一个覆盖全屏的、带有动态噪声Alpha通道的矩形来实现。山色微变让山的颜色随着时间frameCount极其缓慢地变化模拟光线或季节的细微推移。这需要修改Mountain类的calculateColor()方法引入一个时间因子。这里以云雾效果为例// 在全局变量中定义云雾参数 float cloudTime 0; void draw() { // ... 绘制背景和山体的代码同上 ... // 绘制动态云雾 drawMovingClouds(); cloudTime 0.01; // 控制云雾流动速度 } void drawMovingClouds() { loadPixels(); for (int x 0; x width; x) { for (int y 0; y height; y) { // 使用两套不同频率的噪声叠加模拟更自然的云纹 float noise1 noise(x * 0.005, y * 0.005, cloudTime); float noise2 noise(x * 0.01 1000, y * 0.01 1000, cloudTime * 0.7); float cloudAlpha (noise1 * 0.6 noise2 * 0.4) * 60; // 合并并映射到透明度0-60 // 获取当前像素颜色 int index x y * width; color c pixels[index]; // 将云雾颜色这里用白色与背景色按透明度混合 color cloudColor color(255, 255, 255, cloudAlpha); pixels[index] lerpColor(c, cloudColor, cloudAlpha / 255.0); } } updatePixels(); }这个云雾效果计算量较大对性能有要求。在实际操作中为了流畅性我通常会降低云雾纹理的分辨率比如每4个像素采样一次或者使用PGraphics离屏缓冲区预先计算。动态化之后整个画面顿时有了呼吸感山水仿佛沉浸在流动的时光与雾气中更接近“行空”的意境。4. 参数调优、视觉打磨与输出代码跑起来只是第一步要让生成的结果有“青绿山水”的韵味而不是一堆杂乱的颜色块大量的参数调优和视觉打磨必不可少。这个过程没有捷径就是不断地试错、观察、调整。4.1 关键参数与“手感”以下是一些我经过大量测试后觉得影响巨大的参数以及它们的调试心得参数类别具体参数影响效果调试建议与心得山形生成noiseScale(噪声频率)控制山体轮廓的起伏“粒度”。值越大起伏越细碎、密集值越小轮廓越平滑、宏大。前景山可用较大值如0.03-0.05表现细节远景山用较小值如0.005-0.01体现朦胧感。可以结合层数动态映射。noiseAmp(噪声振幅)控制山体轮廓起伏的“幅度”。值越大山形越陡峭、夸张值越小山形越平缓。避免所有山使用相同振幅。前景振幅可大些中景次之远景最小。与高度(height)关联设置比例如height * 0.3比固定值更可控。迭代系统scaleFactor(子山缩放比)决定迭代过程中山体缩小的速度。值越接近1山体大小变化慢构图易显拥挤值越小大小变化快空间纵深感强。范围通常在0.5-0.8之间。可以引入随机性random(0.5, 0.7)避免过于规律。childrenPerParent(子山数量)控制山体的“繁殖”密度。数量多则层峦叠嶂数量少则疏朗有致。不要恒定值。用int(random(1, 4))这样的随机范围并可以随着层数增加而减少远景山衍生更少。子山位置偏移范围控制子山相对于母山的横向和纵向散布范围。横向偏移可稍大模拟山脉的延展纵向偏移向上需严格控制避免子山跑到母山下面去。使用constrain()函数限定在合理画布区域。色彩系统主色相 (baseHue)决定整体色调是偏青还是偏绿。150-170度是典型的青绿色区。可以尝试微调170以上偏蓝130以下偏黄绿感受不同。饱和度/明度随层深衰减系数控制色彩随空间推远而变淡、变冷的程度。这是营造空间感的关键远景的饱和度(S)和明度(B)一定要明显降低。衰减系数可以是非线性的比如用pow(depthFactor, 1.5)让远景衰减更快。构图与全局前景山基线 (y坐标)决定画面“地平线”的高低影响构图重心。基线太低y值大天空留白多画面空灵基线太高山体充盈画面饱满。多试试height * 0.6到height * 0.8之间的值。画布宽高比直接影响画面的气势。尝试不同的比例。瘦长的竖构图如9:16适合表现山的高耸宽阔的横构图如16:9更适合展现“千里”的绵延。调试时我强烈建议将关键参数设置为全局变量并在draw()函数里用简单的交互比如键盘按键来实时调整它们并立即看到效果。Processing 的keyPressed()函数非常适合做这个。// 示例通过按键实时调整噪声频率和缩放因子 float noiseScaleGlobal 0.02; float scaleFactorGlobal 0.65; void keyPressed() { if (key a) { noiseScaleGlobal 0.001; } else if (key s) { noiseScaleGlobal - 0.001; } else if (key z) { scaleFactorGlobal 0.01; } else if (key x) { scaleFactorGlobal - 0.01; } else if (key ) { // 空格键重新生成 generateMountainRange(); } // 记得在generateMountainRange函数和Mountain类中使用这些全局变量 println(noiseScale: noiseScaleGlobal , scaleFactor: scaleFactorGlobal); }这种实时调试的方式效率远超改代码、运行、查看的循环能帮你快速建立起对参数的“手感”。4.2 后期处理与输出技巧直接由程序生成的图像有时会显得有点“生”或“平”。在输出前进行一些简单的数字后期处理能极大提升质感。叠加纸质纹理找一张高清的宣纸或绢布纹理图片确保无版权问题以“叠加”Overlay或“正片叠底”Multiply的混合模式叠在生成的画面上能立刻增添手绘的材质感和岁月痕迹。在 Processing 中可以用blendMode(MULTIPLY)实现。添加微妙的噪点在最终图像上添加一层极细微的、单色的噪点Grain可以打破数字图像过于平滑的质感让画面更耐看。可以用filter()函数轻微实现或者后期在 Photoshop/GIMP 中处理。色彩微调整体调整色相/饱和度/对比度。有时程序生成的整体色调可能偏冷或偏艳可以在导出后用图像软件稍微调整使其更符合心中“青绿”的温润感。高分辨率输出Processing 默认的size()设置的是显示窗口大小。要输出印刷级或大幅面图像需要使用createGraphics()创建一个离屏的高分辨率画布进行绘制然后保存。例如可以创建一张 6000x4000 像素的PGraphics对象按比例调整所有绘制坐标和参数最后保存为 TIFF 或 PNG 格式。void saveHighRes() { int outputWidth 6000; int outputHeight 4000; float scaleFactor outputWidth / (float)width; // 计算缩放比例 PGraphics hiRes createGraphics(outputWidth, outputHeight); hiRes.beginDraw(); hiRes.colorMode(HSB, 360, 100, 100, 100); hiRes.background(220, 30, 95); // 注意所有绘制坐标、尺寸都需要乘以 scaleFactor // 例如原来 position.x 需要变为 position.x * scaleFactor // 这是一个细致活可能需要重写部分绘制函数或传递scaleFactor参数 // ... 重绘所有山体到 hiRes 图形上下文 ... hiRes.endDraw(); hiRes.save(千里江山_高分辨率_ timestamp() .tiff); println(高分辨率图像已保存。); }这个高分辨率渲染的过程可能比较慢因为要计算数百万个像素点。耐心等待是值得的你会得到一张细节丰富的数字艺术作品。5. 创作反思当代码遇见传统美学做完这个项目我最大的感触是用算法生成艺术并不是要取代人类艺术家而是开辟了一种新的对话和探索方式。在这个过程中我遇到了几个深层次的挑战和思考。首先是“可控的随机”与“意境的不可控”。我们可以通过参数精确控制山的大小、颜色范围、生成数量但最终画面所呈现的气韵、节奏、疏密关系却存在很大的随机性。这有点像中国画中的“写意”在一定的法度程序规则内追求偶然的天趣随机结果。我经常需要生成几十甚至上百张图才能从中挑选出几张构图、色彩、意境都让我觉得“对味”的。这迫使我去思考究竟什么样的算法产出才符合“青绿山水”的美学标准这个标准本身是模糊的、感性的而代码是精确的、理性的。二者的碰撞正是这个项目最迷人的地方。其次是细节与整体的平衡。在调试时很容易陷入对单个山形轮廓或某一处色彩过渡的纠结而忽略了整体构图。有时候参数微调后单看一座山变好看了但整体画面却失去了节奏感。我学到的方法是频繁地缩小画布视图从整体效果出发进行评估同时为不同的“层”设置差异化的参数组而不是全局统一。例如让前景山的轮廓噪声更复杂远景山的色彩变化更柔和这样自然就形成了视觉焦点和空间纵深感。最后是关于“创新”的边界。这个项目最初是向《千里江山图》致敬但做着做着我开始想能不能加入一些非传统的元素比如用算法生成的山形数据去驱动一段音乐数据声效化或者将昼夜循环、四季变化用色彩参数的变化表现出来甚至能否引入交互让观看者的鼠标移动或点击像一阵风或一滴墨一样影响山峦的生成这些想法有些实现了有些还停留在构思。它们可能已经偏离了纯粹的“青绿”模仿但我觉得这才是数字艺术的生命力所在——它不满足于复现更渴望在传统基因上进行变异和生长。回过头看“千里江山入行空只此为青绿”这个标题恰好概括了整个过程我们用代码在数字的“行空”之中构建了一片源于“青绿”但又独一无二的山水。它是不确定的、动态的、可无限衍生的。每一次运行都是对这片心象风景的一次新的抵达。如果你也对此感兴趣不妨从最基础的噪声函数画一座小山开始慢慢添加色彩、层叠和动态。最重要的不是复现我的代码而是找到你自己与这种古老美学进行数字对话的方式。参数没有标准答案美也没有那些在调试过程中偶然诞生的、令你心头一动的画面就是最好的答案。