从高度图生成法线贴图?

43
我正在为一个视频游戏使用随机分形生成脏土块,已经使用中点位移算法生成了高度图并将其保存到纹理中。我有一些想法可以把它转化为法线贴图,但是欢迎提供反馈。
我的高度纹理目前是一个257x257的灰度图像(高度值被缩放以用于可视化): enter image description here 我的思路是图像的每个像素表示在256x256网格中的一个晶格坐标(因此,高度是257 x 257)。这意味着坐标(i, j)处的法线由(i,j)、(i, j+1)、(i+1,j)和(i+1,j+1)处的高度决定(分别称为A、B、C和D)。
因此,基于A、B、C和D的3D坐标,是否有意义:
1. 将四个点分成两个三角形:ABC和BCD 2. 通过叉积计算这两个面的法线 3. 将其分成两个三角形:ACD和ABD 4. 计算这两个面的法线 5. 对这四个法线取平均
还是我遗漏了更简单的方法?

2
我会做类似的事情,只是我会使用四个点(i,j + 1),(i + 1,j),(i,j-1)和(i-1,j)来计算法线,以便(i,j)位于它们的中心。无论如何,我认为你走在了正确的道路上 :) - tux21b
1
对于“更简单”的方法,您需要知道描述x,y处高度的函数,例如h = f(x,y),然后可以从中推导出x,y处法线函数...除非您有此函数,否则您的方法是最好的;) - Raven
4个回答

46

这是我水面渲染着色器中的GLSL示例代码:

#version 130
uniform sampler2D unit_wave
noperspective in vec2 tex_coord;
const vec2 size = vec2(2.0,0.0);
const ivec3 off = ivec3(-1,0,1);

    vec4 wave = texture(unit_wave, tex_coord);
    float s11 = wave.x;
    float s01 = textureOffset(unit_wave, tex_coord, off.xy).x;
    float s21 = textureOffset(unit_wave, tex_coord, off.zy).x;
    float s10 = textureOffset(unit_wave, tex_coord, off.yx).x;
    float s12 = textureOffset(unit_wave, tex_coord, off.yz).x;
    vec3 va = normalize(vec3(size.xy,s21-s01));
    vec3 vb = normalize(vec3(size.yx,s12-s10));
    vec4 bump = vec4( cross(va,vb), s11 );

结果是一个凸起向量:xyz=法线,a=高度。


1
这是一个很好的解决方案,但我不得不做一些更改:`vec3 va = normalize(vec3(size.x, s21-s01, size.y)); vec3 vb = normalize(vec3(size.y, s12-s10, -size.x));`虽然交换Y和Z并不是什么大问题,但我认为有趣的是,我不得不减去s21-s01而不是示例中所示的s21-s11。我还不得不否定vb中的size.x - sinisterchipmunk
2
只是出于好奇,根据您的经验,从准备好的法线贴图计算凹凸映射是否更有效,还是在片段着色器中从高度图上动态获取法线?如果这种方法更快,我不会感到惊讶,因为您读取的纹理数据比使用法线贴图少4倍,而且您甚至不需要切线或副法线作为插值器或顶点属性。 另一方面,这种方法在片段阶段的ALU和SF上更加繁重... - ds-bos-msk
@bigD 通过传递切线/双切线与从高度图中导出法线没有任何关系 - 这是高度/法线图解释的问题。高度图用于水,因为它是某些模拟算法的输出。对于其他情况,常规法线图更有效。 - kvark
@kvark 是的,抱歉你是对的。通常仍需要传递切线/副法线或四元数之类的东西。虽然你可以使用GLSL内置的导数函数从纹理坐标中推导出方向信息(用于法线贴图或高度图),但这可能会产生一些严重的视角相关误差/不准确性... - ds-bos-msk
我不太明白为什么向量vavb必须使用2.0, 0.0进行计算。为什么我们需要恰好是数字20,从而得到向量(2, 0, s21-s01)(0, 2, s12-s10)。有人能用数学的方式解释一下吗? - j00hi
显示剩余4条评论

21

我认为图像的每个像素表示256 x 256网格中的一个晶格坐标(因此有257 x 257个高度)。这意味着在坐标(i,j)处的法线取决于(i,j),(i,j + 1),(i + 1,j)和(i + 1,j + 1)处的高度(分别称为A,B,C和D)。

不是的。图像的每个像素表示网格的一个顶点,因此根据对称性,它的法线由相邻像素(i-1,j),(i +1,j),(i,j-1),(i,j+1)的高度确定。

给定一个描述实数3中曲面的函数f:ℝ2 → ℝ,(x,y)处的单位法线为:

v = (−∂f/∂x,−∂f/∂y,1) 且 n = v/ |v|。

可以证明,通过两个样本最好逼近∂f/∂x的方法是:

∂f/∂x(x,y) = (f(x+ε,y) − f(x−ε,y))/(2ε)

要获得更好的逼近结果,您需要使用至少四个点,因此添加第三个点(即(x,y))不会改善结果。

您的高度图是正则网格上某个函数f的采样。取ε=1,您会得到:

2v = (f(x−1,y) − f(x+1,y), f(x,y−1) − f(x,y+1), 2)

将其转化为代码的形式如下:

// sample the height map:
float fx0 = f(x-1,y), fx1 = f(x+1,y);
float fy0 = f(x,y-1), fy1 = f(x,y+1);

// the spacing of the grid in same units as the height map
float eps = ... ;

// plug into the formulae above:
vec3 n = normalize(vec3((fx0 - fx1)/(2*eps), (fy0 - fy1)/(2*eps), 1));

这里的eps是什么? - Mike 'Pomax' Kamermans
@Mike'Pomax'Kamermans 你的实现中格子的间隔。在注释中有说明。 - Yakov Galka
间距是多少呢?我从图形的角度来看待这个问题,对于像素来说,它们只是像素,没有所谓的“间距”。 - Mike 'Pomax' Kamermans
@Mike'Pomax'Kamermans 如果你只处理一个孤立的二维图像,谈论法线是没有意义的。法线是指在三维空间中垂直于表面的方向;因此,你首先需要根据你的二维高度图定义三维表面,然后计算法线。这个答案假设在XY平面上创建了一个规则网格,间距为eps,然后顶点沿着Z轴根据高度图的要求进行位移。这通常是在简单的平坦世界中(如OP中)的常用方法。 - Yakov Galka
这里的Eps只是1。它是在计算fx0时添加到例如x的内容。在此处查看更多信息:https://en.wikipedia.org/wiki/Finite_difference_method - vidstige
@vidstige 这不是真的;就像我已经说过多次的那样,这是网格间距的问题。例如,如果你的世界空间中的网格间距为2米,那么你需要取eps=2,否则你将无法得到正确的法线角度。 - Yakov Galka

16

一种常见的方法是使用Sobel滤波器对每个方向进行加权/平滑导数。

从每个像素周围采样一个3x3高度区域开始(这里,[4]是我们想要法线的像素)。

[6][7][8]
[3][4][5]
[0][1][2]

那么,

//float s[9] contains above samples
vec3 n;
n.x = scale * -(s[2]-s[0]+2*(s[5]-s[3])+s[8]-s[6]);
n.y = scale * -(s[6]-s[0]+2*(s[7]-s[1])+s[8]-s[2]);
n.z = 1.0;
n = normalize(n);

其中scale可以调整以匹配高度图在其大小相对于实际世界深度方面的实际深度。


谢谢。对于我这样的人来说,我正在将其实现为MacOS的核心图像滤镜,并且一直得到最奇怪的结果 - 每次使用相同的图像运行滤镜时结果都不同。原来是某些人可能认为是新手错误的东西 - 我是内核新手。它不喜欢公式中的数字文字。我创建了一个常量const float d = 2.0并将其替换为计算中的2,然后它就完美地工作了。 - Ash
这个算法生成了完全不同的图像,但不幸的是,它检测到的边缘和平坦区域的颜色不同(在正常的法线贴图中,大部分都是平坦的蓝色)。我想留下评论,因为我浪费了时间进行移植。 - Richard
3
尝试将结果从区间[-1, 1]缩放到[0, 1],即使用公式n * 0.5 + 0.5 - jozxyqk

8
如果您将每个像素视为一个顶点而不是一个面,那么您可以生成一个简单的三角网格。
+--+--+
|\ |\ |
| \| \|
+--+--+
|\ |\ |
| \| \|
+--+--+

每个顶点具有与地图中像素的x和y对应的坐标。z坐标基于该位置处地图上的值。三角形可以通过其在网格中的位置明确或隐式生成。
你需要的是每个顶点的法线。
可以通过取加权平均面法线来计算顶点法线,这些面法线是与该点相交的每个三角形的面法线。
如果您有一个由顶点v0,v1,v2组成的三角形,则可以使用向量叉积(沿着三角形两条边之一的两个向量)来计算指向法线方向并按比例缩放到三角形面积的向量。
Vector3 contribution = Cross(v1 - v0, v2 - v1);

每个不在边缘上的顶点将被六个三角形共享。您可以循环遍历这些三角形,总结各自的contribution,然后规范化向量和。

注意: 您必须以一致的方式计算叉乘,以确保法线都指向同一个方向。始终按相同顺序(顺时针或逆时针)选择两个侧面。如果混淆其中一些,则这些贡献将指向相反的方向。

对于边缘上的顶点,您最终得到一个较短的循环和许多特殊情况。创建围绕虚拟顶点网格的边界,然后计算内部顶点的法线并丢弃虚拟边界可能更容易。

for each interior vertex V {
  Vector3 sum(0.0, 0.0, 0.0);
  for each of the six triangles T that share V {
    const Vector3 side1 = T.v1 - T.v0;
    const Vector3 side2 = T.v2 - T.v1;
    const Vector3 contribution = Cross(side1, side2);
    sum += contribution;
  }
  sum.Normalize();
  V.normal = sum;
}

如果您需要在三角形的特定点(除了顶点之一)处的法线,您可以通过按照该点的重心坐标加权三个顶点的法线来进行插值。这就是图形光栅化器用于着色的法线处理方式。它使得三角网格看起来像平滑的曲面,而不是一堆相邻的平面三角形。

提示: 对于您的第一个测试,请使用完全平坦的网格,并确保所有计算出的法线都朝上。


@Adian McCarthy - 很遗憾,我认为我负担不起生成实际网格的费用,特别是因为它只是一小块平坦的土地。但无论如何,还是谢谢你的解释! - Dawson

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接