1. 网格噪声核心原理:从Worley算法说起
1.1 什么是Cellular Noise
做图形学的人应该都有这种感觉:Perlin噪声用久了,画面总带着一种"云雾感",做地形、做水面还行,但一旦想表达"细胞""裂纹""石壁""鳞片"这类带有明显边界感的东西,就明显不够用了。后来我在一个项目里需要做生物组织的程序化贴图,才正儿八经去研究Cellular Noise(网格噪声)。这个噪声算法最早由Steven Worley在1996年提出,所以也常被称为Worley Noise。它的核心思想非常直观:在空间中撒一堆"特征点",然后对任意一个查询位置,计算它到最近特征点的距离,用这个距离值来驱动颜色、亮度或透明度。距离小的地方像一个"细胞核"附近,距离大的地方像细胞边界,整体呈现一种天然的镶嵌结构。
相比Perlin或Simplex这类基于渐变向量的连续噪声,Cellular Noise的特点是"边界清晰、区域感强",特别适合做蜂窝、龟裂土地、大理石裂纹、水面涟漪这类效果。在OpenGL和WebGL里,它通常以着色器片段程序的形式运行,也就是说每个像素都独立计算一次噪声值,完全并行,非常契合GPU的架构。这篇文章我会从原理讲到GLSL实现,再讲OpenGL和WebGL两个平台的具体落地,最后把我在实际项目中踩过的坑和优化经验一并整理出来。
1.2 F1与F2:距离场才是灵魂
Cellular Noise的输出并不只有一种。标准做法是计算每个查询点到周围所有特征点的距离,然后从中取第一小和第二小的两个值,图形学里习惯叫F1和F2。
用数学语言描述就是:
code复制F1 = min(dist(p, point_i))
F2 = second_min(dist(p, point_i))
取F1时,画面表现为"离最近特征点越近越亮"的细胞状团块;取F2减F1时,得到的是两个最近特征点之间的等距线,也就是细胞壁的轮廓。这两种输出的差别,决定了你最终得到的是"细胞实体"还是"细胞骨架"。我一开始没想清楚这层关系,直接用F1去模拟裂纹,结果做出来像一团圆形斑块,换了F2-F1之后才真正出现连续的线条感。
另外,距离本身也可以用不同方式定义。欧氏距离最自然,但曼哈顿距离(各轴绝对值之和)和切比雪夫距离(各轴绝对值取最大)会带来完全不同的几何形态。比如切比雪夫距离会产生方块状细胞,曼哈顿距离会产生菱形纹理。在做风格化效果时,可以试着改一下距离度量,经常会有惊喜。
1.3 为什么必须是网格法:GPU并行与复杂度权衡
理论上最朴素的实现方式是暴力搜索:把所有特征点存进数组,然后对每个像素遍历全部特征点,取最小距离。这种方式在特征点数量少的时候没问题,但一旦特征点数量上到几百上千,GPU的每个像素都要做同样数量的距离计算,性能直接崩掉。
Cellular Noise的标准做法是"网格法"。思路是:把空间划分为边长为1的规则网格,每个网格单元内部随机放置一个特征点。查询某一点时,它的最近特征点只可能落在自身所在网格以及周围一圈共9个网格内,所以只需要检查3x3共9个候选点,计算量完全可控。这个结论的直观解释是:任意一个特征点只会在它自己的网格内随机出现,它最远也不可能跑到别的网格里,而查询点到其他网格的距离本身已经限制了候选范围。
网格法的优势在GPU上尤其明显:每个像素处理的数据量固定,没有动态数组,没有无法预测的分支,循环次数在编译期就是常量。后面我会看到GLSL实现里那个双层for循环,次数写死为9次,这正是为GPU并行量身定制的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. OpenGL环境下的完整实现
2.1 准备工作:最小OpenGL渲染框架
在动手写Cellular Noise之前,先把渲染框架搭好。我的方案是渲染一个覆盖整个视口的全屏四边形,把噪声逻辑全部放在片段着色器里,这样做最直接,也方便后续把噪声结果输出到纹理。
OpenGL这边我用的版本是3.3 Core Profile,顶点着色器只需要传递UV坐标,片段着色器里做所有计算。如果你用的是Qt或者GLFW这类窗口库,流程都一样:创建窗口和OpenGL上下文,编译顶点和片段着色器,链接成程序,创建VAO/VBO,循环渲染。
一个值得注意的细节是:全屏四边形最好用三角形strip绘制,只用4个顶点,避免重复顶点带来的冗余带宽。顶点坐标我设为(-1,-1)到(1,1),UV从(0,0)到(1,1),顶点着色器里直接把顶点坐标复制到gl_Position,再把UV传给片段着色器。
2.2 GLSL实现基础Cellular Noise
核心代码在片段着色器里。先用一个hash函数在网格内生成伪随机特征点,再用双层循环遍历3x3邻居网格,计算出F1和F2。
glsl复制#version 330 core
out vec4 FragColor;
in vec2 vUv;
uniform float uScale;
uniform float uSeed;
vec2 hash(vec2 p) {
p = vec2(dot(p, vec2(127.1, 311.7)),
dot(p, vec2(269.5, 183.3)));
return fract(sin(p) * 43758.5453);
}
vec2 cellular(vec2 p) {
vec2 cell = floor(p);
vec2 f = fract(p);
float f1 = 8.0;
float f2 = 8.0;
for (int j = -1; j <= 1; j++) {
for (int i = -1; i <= 1; i++) {
vec2 neighbor = vec2(float(i), float(j));
vec2 point = cell + neighbor + hash(cell + neighbor + uSeed);
float d = length(point - p);
if (d < f1) {
f2 = f1;
