多八度分形布朗运动形变平面的正确法线计算问题排查
我使用OpenGL,通过函数$y = A \cdot e^{\sin(k \cdot (x\cos\theta + z\sin\theta) + \omega t) - 1}$对水平平面的顶点进行垂直位移以模拟水波。随后通过该函数对x和z的偏导数计算每个顶点的法线向量,构造切线和副法线向量后取叉积得到最终法线向量,以此计算漫反射和高光光照。单八度分形布朗运动时效果良好,但使用多八度时,画面开始出现黑斑及类似六边形斑块的视觉伪影。
效果对比
- 1八度光照效果
- 32八度光照效果
- 1八度法线贴图
- 32八度法线贴图
核心代码
初始平面顶点与索引生成(src/Application.cpp)
struct Vertex{ float x; float y; float z; }; // Plane vertices std::vector<Vertex> vertices; float size = 10.0f; int subdivisions = 100; float tilewidth = size / static_cast<float>(subdivisions); for (int z = 0; z < subdivisions + 1; z++) { for (int x = 0; x < subdivisions + 1; x++) { vertices.push_back(Vertex{x * tilewidth, 0.0f, z * tilewidth}); } } // Plane indices std::vector<unsigned int> indices; for (int z = 0; z < subdivisions; z++) { for (int x = 0; x < subdivisions; x++) { int index = (z * (subdivisions + 1)) + x; indices.insert(indices.end(), { index, index + (subdivisions + 1) + 1, index + (subdivisions + 1), index, index + 1, index + (subdivisions + 1) + 1, }); } }
顶点着色器(res/ocean_simple.shader)
#version 330 core layout (location = 0) in vec3 aPos; uniform mat4 model; uniform mat4 view; uniform mat4 projection; uniform float time; out vec3 normal; out vec3 FragPos; const int NUM_OCTAVES = 32; #define pi 3.141592653589793 // Rotate a vec2 vec2 rotate(vec2 vec, float rot) { float s = sin(rot), c = cos(rot); return vec2(vec.x*c-vec.y*s, vec.x*s+vec.y*c); } void main() { float y = 0.0; float xz = 0.0; vec2 direction = vec2(cos(0.5), sin(0.5)); vec2 derivative = vec2(0.0, 0.0); float frequency = 1.0; float amplitude = 1.0; float timeMultiplier = 1.0; // iterate over all octaves for (int i = 0; i < NUM_OCTAVES; i++) { // (x,z) position dotted with our wave direction gives us a scalar to feed into our e^(sin(x)-1), making the wave go in that direction xz = dot(aPos.xz, direction); // increase y height y += amplitude * exp(sin(xz * frequency + time * timeMultiplier) - 1.0); // increase partial derivatives with respect to x and z (i think these are correct partial derivatives?) derivative += amplitude * frequency * direction * cos(xz * frequency + time * timeMultiplier) * exp(sin(xz * frequency + time * timeMultiplier) - 1.0); // change frequency and amplitude and wave direction for next octave - fractal brownian motion effect amplitude *= 0.5; frequency *= 2.0; direction = rotate(direction, 0.125*pi); } // Normal vector is cross of tangent and binomrial vector normal = normalize(cross(vec3(1.0, derivative.x, 0.0), vec3(0.0, derivative.y, -1.0))); gl_Position = projection * view * model * vec4(aPos.x, y, aPos.z, 1.0); FragPos = vec3(model * vec4(aPos.x, y, aPos.z, 1.0)); }
片段着色器(res/ocean_simple.shader)
#version 330 core out vec4 FragColor; in vec3 normal; in vec3 FragPos; uniform vec3 viewPos; vec3 sunDirection = normalize(vec3(cos(0.5), 0.5, sin(0.5))); void main() { // diffuse is dot product of normal vector and sun direction - lamberts cosine law float diffuse = max(dot(normal, sunDirection), 0.0); // specular highlights vec3 viewDir = normalize(viewPos - FragPos); vec3 reflectDir = reflect(-sunDirection, normal); float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32); FragColor = vec4(65, 107, 223, 225) / 255.0 * (diffuse + spec); }
请问是什么原因导致法线向量出现此类六边形黑斑伪影?
核心原因:数值精度丢失+周期性方向叠加+法线构造逻辑瑕疵
高八度下的单精度浮点数精度崩溃
当开启32个八度时,高频分量的frequency会达到$2^{31}$级别,结合平面顶点aPos.xz的取值范围(0~10),计算出的xz * frequency会远超单精度浮点数的有效精度范围(单精度有效位数仅约7位)。此时sin、cos函数的计算会出现严重的精度丢失,导数derivative值产生随机跳变或错误,直接导致法线向量方向突变,表现为画面中的黑斑。方向向量旋转的周期性六边形叠加
每个八度中你对方向向量固定旋转0.125π(22.5度),16次旋转后向量会回到初始方向。这种固定周期的旋转会让多八度的方向向量形成对称的六边形分布,导致导数分量在特定区域出现规律性的抵消或叠加,使得法线向量出现一致的错误方向,最终形成六边形斑块。切线与副法线构造的逻辑问题
对于函数$y = f(x,z)$,正确的x方向切线应为$\vec{T} = (1, \frac{\partial f}{\partial x}, 0)$,z方向副法线应为$\vec{B} = (0, \frac{\partial f}{\partial z}, 1)$,法线是$\vec{T} \times \vec{B}$的归一化结果。你当前的副法线z分量为-1,会导致法线方向反转;同时,当导数计算出现精度错误时,这种构造会进一步放大错误的影响。
解决建议
- 限制八度数量:单精度环境下,八度数建议不超过8~10个,此时
frequency最大值为512,xz*frequency的结果仍在单精度有效精度范围内。 - 打破方向旋转周期性:改用随机角度或非整数倍的旋转角度,避免方向向量形成对称的六边形分布模式。
- 修正法线计算逻辑:使用正确的切线、副法线构造方式,确保法线方向准确;也可以考虑在片段着色器中用中心差分法重新计算法线,规避顶点着色器的精度丢失传递。
- 启用高精度计算:在顶点着色器中改用
double类型进行计算(GLSL 330及以上支持),大幅提升高频率分量的计算精度。
内容的提问来源于stack exchange,提问作者William Redding

