我一直觉得,创意编程最迷人的地方,是它让我们重新认识那些熟悉的工具。一段循环不只是循环,一个函数也不只是把输入变成输出。它们可以是一片山脉,一阵风,或者一个缓慢变化的黄昏。
这次,我们从最简单的曲线开始,试着用数学画出一片风景。
从一条曲线开始
先忘掉复杂的渲染管线。只需要一个横坐标,再让纵坐标随它起伏,我们就有了一条曲线:
float height = sin(uv.x * frequency) * amplitude;
frequency 决定山丘有多密集,amplitude 决定山丘有多高。只有两个参数,却已经足够产生不同的节奏。
但自然从来不只有一个频率。如果把两条曲线叠加,规则的波浪就会开始拥有更丰富的细节。
float terrain(vec2 uv) {
float broad = sin(uv.x * 3.0) * 0.65;
float detail = sin(uv.x * 6.0 + 0.5) * 0.35;
return broad + detail;
}
好的生成艺术,不是把随机数撒满画布,而是给随机性一个可以呼吸的结构。
让参数变得可见
理解一个公式最好的方式,是亲手改变它。下面是同一个想法的 Canvas 可视化:将不同频率的正弦曲线叠加,再用轮廓线呈现地形。它还不是完整的噪声算法,但很适合建立直觉。
拖动滑块,观察参数如何改变一片风景。
试着先把频率拉到最低,再慢慢增加振幅。你会发现,山脉的性格会随着这两个数字改变:有时像柔和的丘陵,有时像层层叠叠的山峰。
从周期走向噪声
正弦曲线依然太有规律。真实的地形需要一种更连续、又不那么容易被预测的变化,这就是噪声函数擅长的事情。
与直接生成随机数不同,梯度噪声会让相邻采样点之间平滑过渡。当我们将多个不同频率、不同强度的噪声叠加,就得到了常用的 分形布朗运动(fBm)。
float fbm(vec2 position) {
float value = 0.0;
float strength = 0.5;
for (int octave = 0; octave < 5; octave++) {
value += strength * noise(position);
position *= 2.0;
strength *= 0.5;
}
return value;
}
这里的 noise() 需要使用一个实际的梯度噪声实现,例如 Simplex noise。上面的代码展示的是叠加思路,而不是一个可以独立运行的完整 Shader。
为什么要分层
第一层决定整体轮廓,第二层增加山丘,后面的层补充更小的纹理。我们不必一开始就决定每个像素应该在哪里,只要为它们设置一组共同遵守的规则。
这有点像写作:先建立结构,再一点点丰富细节。
让风景呼吸
当时间进入公式,静止的画面就会发生变化。但“会动”与“有生命感”并不是同一件事。
我更喜欢让变化发生得慢一点:云层轻轻漂移,光线缓缓移动,山脊只是出现几乎察觉不到的起伏。动画应该邀请人停留,而不是要求人注意。
同时记得尊重 prefers-reduced-motion。对于希望减少动态效果的读者,一张静止的风景也应该同样完整。
下一次探索
这只是一个很小的起点。下一步可以试试:
- 用颜色映射高度,得到不同的植被层次;
- 把轮廓线换成点阵,观察另一种视觉质感;
- 加入太阳方向,让坡面产生明暗变化;
- 保存喜欢的参数,为自己建立一册数字风景集。
不急着追求复杂。从一个让你好奇的小变化开始,往往就足够了。