【跟着catlikecoding学渲染#5】凹凸——高度与法线

上一章讲了更复杂得光照,这一章讲创造更复杂得表面

一,Bump Mapping

我们可以使用反照率纹理来创建具有复杂颜色图案的材料。我们可以使用法线来调整表观表面曲率。使用这些工具,我们可以生产各种表面。但是,单个三角形的表面将始终是光滑的。它只能在三个法向量之间插值。因此,它不能表示粗糙或多样的表面。当放弃反照率纹理并仅使用纯色时,这一点变得很明显。 这种平坦度的一个很好的例子是一个简单的四边形。将一个添加到场景中,并通过围绕 X 轴旋转 90° 使其指向上方。给它我们的照明材料,没有纹理,具有完全白色的色调。

由于默认的天空盒非常明亮,因此很难看到其他灯光的贡献。因此,让我们在本教程中将其关闭。您可以通过在照明设置中将环境强度降低到零来执行此操作。然后只启用主定向光。在场景视图中找到一个好的视角,以便您可以在四边形上出现一些光线差异。

我们怎样才能使这个四边形看起来不平坦?我们可以通过在反照率纹理中烘烤阴影来伪造粗糙度。但是,这将是完全静态的。如果灯光发生变化,或者物体移动,阴影也应该改变。如果不这样做,幻觉就会被打破。在镜面反射的情况下,即使是相机也不允许移动。 我们可以改变法线,创造弯曲表面的错觉。但是每个四边形只有四个法线,每个顶点一个。这只能产生平滑的过渡。如果我们想要一个多样和粗糙的表面,我们需要更多的法线。

  我们可以将四边形细分为更小的四边形。这为我们提供了更多的正常值。事实上,一旦我们有更多的顶点,我们也可以移动它们。然后我们不需要粗糙的错觉,我们可以制作一个真正的粗糙表面!但子四边形仍然有同样的问题。我们是否也要细分这些?这将导致具有大量三角形的巨大网格。这在创建3D模型时很好,但对于游戏中的实时使用是不可行的。

1.1 Height Maps

与平坦表面相比,粗糙表面具有不均匀的高度。如果我们将此高度数据存储在纹理中,则可以使用它为每个片段生成法向量,而不是每个顶点。这个想法被称为凹凸映射,最初是由James Blinn提出的。 这是一个高度图,以配合我们的大理石纹理。它是一个 RGB 纹理,每个通道都设置为相同的值。使用默认导入设置将其导入到项目中。

将_HeightMap纹理属性添加到shader中。由于它将使用与我们的反照率纹理相同的UV,因此它不需要自己的比例和偏移参数。默认纹理并不重要,只要它是均匀的。灰色就可以了。

Properties {
		_Tint ("Tint", Color) = (1, 1, 1, 1)
		_MainTex ("Albedo", 2D) = "white" {}
		[NoScaleOffset] _HeightMap ("Heights", 2D) = "gray" {}
		[Gamma] _Metallic ("Metallic", Range(0, 1)) = 0
		_Smoothness ("Smoothness", Range(0, 1)) = 0.1
	}

将匹配变量添加到文件,以便我们可以访问纹理。让我们看看它的外观,通过将其分解为反照率。

float4 _Tint;
sampler2D _MainTex;
float4 _MainTex_ST;

sampler2D _HeightMap;

…

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
	i.normal = normalize(i.normal);

	float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);

	float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;
	albedo *= tex2D(_HeightMap, i.uv);

	…
}

1.2 Adjusting Normals

因为我们的片段范式将变得更加复杂,让我们将它们的初始化转移到一个单独的函数。另外,摆脱高度图测试代码。

void InitializeFragmentNormal(inout Interpolators i) {
	i.normal = normalize(i.normal);
}

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
	InitializeFragmentNormal(i);

	float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);

	float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;
//	albedo *= tex2D(_HeightMap, i.uv);

	…
}

因为我们当前使用的是位于 XZ 平面中的四边形,所以它的法向量始终是 (0, 1, 0)。因此,我们可以使用常量法线,忽略顶点数据。让我们现在这样做,稍后再担心不同的方向。

void InitializeFragmentNormal(inout Interpolators i) {
	i.normal = float3(0, 1, 0);
	i.normal = normalize(i.normal);
}

我们如何将身高数据包含在其中?一种幼稚的方法是在归一化之前使用高度作为法线的Y分量。

void InitializeFragmentNormal(inout Interpolators i) {
	float h = tex2D(_HeightMap, i.uv);
	i.normal = float3(0, h, 0);
	i.normal = normalize(i.normal);
}

这不起作用,因为归一化会将每个向量转换回 (0, 1, 0)。黑线出现在高度为零的位置,因为在这些情况下归一化失败。我们需要一种不同的方法。

1.3 Finite Difference

因为我们使用的是纹理数据,所以我们有二维数据。有 U 和 V 维度。高度可以被认为是在第三维度上向上移动。我们可以说纹理表示一个函数,f( u , v ) = h 。让我们首先将自己限制在U维数上。所以函数简化为 f( u ) = h我们可以从这个函数推导出正态向量吗?

  如果我们知道函数的斜率,那么我们可以使用它来计算其在任何时候的法线。斜率由 h 的变化率定义。这是它的导数,h ′ 。因为 h 是函数的结果,所以 h ′  也是函数的结果。所以我们有导数函数 f ′(u ) = h ′。

  不幸的是,我们不知道这些功能是什么。但我们可以近似它们。我们可以比较纹理中两个不同点的高度。例如,在最末端,使用 U 坐标 0 和 1。这两个样本之间的差值是这些坐标之间的变化率。表示为函数,即  f(1)-f(0)。我们可以用它来构造一个切向量,

这当然是实切向量的非常粗略的近似。它将整个纹理视为线性斜率。我们可以通过对两个距离较近的点进行采样来做得更好

通常,我们必须相对于我们渲染的每个片段的U坐标执行此操作。到下一个点的距离由常量增量定义。因此,导数函数近似于

1.4 From Tangent to Normal 

我们可以在着色器中为δ使用什么值?最小的合理差异将覆盖我们纹理的单个纹理。我们可以通过带有_TexelSize后缀的 float4 变量在着色器中检索此信息。Unity 设置这些变量,类似于_ST变量。

sampler2D _HeightMap;
float4 _HeightMap_TexelSize;

What is stored in _TexelSize variables?

它的前两个分量包含纹素大小,作为 u 和 V 的分数。其他两个组件包含像素数。例如,如果是 256×128 纹理,它将包含 (0.00390625、0.0078125、256、128)


现在,我们可以对纹理进行两次采样,计算高度导数,并构造一个切向量。让我们直接将其用作我们的正态向量。

	float2 delta = float2(_HeightMap_TexelSize.x, 0);
	float h1 = tex2D(_HeightMap, i.uv);
	float h2 = tex2D(_HeightMap, i.uv + delta);
	i.normal = float3(1, (h2 - h1) / delta.x, 0);

	i.normal = normalize(i.normal);

实际上,因为我们无论如何都在归一化,所以我们可以通过δ来缩放我们的切向量。这消除了分裂并提高精度。

	i.normal = float3(delta.x, h2 - h1, 0);

我们得到了一个非常明显的结果。这是因为高度的范围为一个单位,这会产生非常陡峭的斜坡。由于扰动法线实际上不会改变表面,因此我们不希望出现如此巨大的差异。我们可以通过任意因子来缩放高度。让我们将范围缩小到单个纹理。我们可以通过将高度差乘以δ来做到这一点,或者简单地将δ替换为切线中的1。

i.normal = float3(1, h2 - h1, 0);

这开始看起来不错,但照明是错误的。天太黑了。这是因为我们直接使用切线作为法线。要将其转换为向上指向的法向量,我们必须围绕 Z 轴将切线旋转 90°。

	i.normal = float3(h1 - h2, 1, 0);

How does that vector rotation work?

通过交换矢量的 X 和 Y 分量并翻转新 X 分量的符号,可以逆时针旋转 2D 矢量 90°

1.5 Central Difference

我们使用有限差分近似来创建正态向量。具体来说,通过使用前向差分法。我们取一个点,然后向一个方向看以确定斜率。结果,法线偏向于该方向。为了获得更好的法线近似值,我们可以在两个方向上偏移采样点。这将线性近似集中在当前点上,称为中心差分法

float2 delta = float2(_HeightMap_TexelSize.x * 0.5, 0);
	float h1 = tex2D(_HeightMap, i.uv - delta);
	float h2 = tex2D(_HeightMap, i.uv + delta);
	i.normal = float3(h1 - h2, 1, 0);

这会稍微移动凸起,因此它们可以更好地与高度场对齐。除此之外,它们的形状不会改变。

1.6 Using Both dimensions

我们创建的法线仅考虑沿 U 的变化。我们一直在使用函数 f(u,v) 相对于 u 的偏导数。那是  fu′(u,v),或者只是 f ′ u简称。我们还可以沿 V 创建法线

float2 du = float2(_HeightMap_TexelSize.x * 0.5, 0);
	float u1 = tex2D(_HeightMap, i.uv - du);
	float u2 = tex2D(_HeightMap, i.uv + du);
	i.normal = float3(u1 - u2, 1, 0);

	float2 dv = float2(0, _HeightMap_TexelSize.y * 0.5);
	float v1 = tex2D(_HeightMap, i.uv - dv);
	float v2 = tex2D(_HeightMap, i.uv + dv);
	i.normal = float3(0, 1, v1 - v2);

	i.normal = normalize(i.normal);

现在,我们可以访问 u 和 V 切线。这些向量一起描述了我们片段处高度场的表面。通过计算它们的交叉积,我们找到了2D高度场的法向量。

float2 du = float2(_HeightMap_TexelSize.x * 0.5, 0);
	float u1 = tex2D(_HeightMap, i.uv - du);
	float u2 = tex2D(_HeightMap, i.uv + du);
	float3 tu = float3(1, u2 - u1, 0);

	float2 dv = float2(0, _HeightMap_TexelSize.y * 0.5);
	float v1 = tex2D(_HeightMap, i.uv - dv);
	float v2 = tex2D(_HeightMap, i.uv + dv);
	float3 tv = float3(0, v2 - v1, 1);

	i.normal = cross(tv, tu);
	i.normal = normalize(i.normal);

What's a cross product?

void InitializeFragmentNormal(inout Interpolators i) {
	float2 du = float2(_HeightMap_TexelSize.x * 0.5, 0);
	float u1 = tex2D(_HeightMap, i.uv - du);
	float u2 = tex2D(_HeightMap, i.uv + du);
//	float3 tu = float3(1, u2 - u1, 0);

	float2 dv = float2(0, _HeightMap_TexelSize.y * 0.5);
	float v1 = tex2D(_HeightMap, i.uv - dv);
	float v2 = tex2D(_HeightMap, i.uv + dv);
//	float3 tv = float3(0, v2 - v1, 1);

//	i.normal = cross(tv, tu);
	i.normal = float3(u1 - u2, 1, v1 - v2);
	i.normal = normalize(i.normal);
}

二,Normal Mapping

虽然凹凸贴图有效,但我们必须执行多个纹理样本和有限差值计算。这似乎是一种浪费,因为由此产生的正常值应该始终相同。为什么这一切每一帧都有效?我们可以做一次,并将法线存储在纹理中。

Does this work with texture filtering?

双线性和三线性滤波将在法向量之间混合,就像法线在三角形之间插值一样。因此,我们必须对采样的法线进行归一化。 您还必须确保每个 mipmap 都包含有效的法线。您不能简单地对纹理进行缩减像素采样,就好像它包含颜色数据一样。向量也必须归一化。


他的意思是我们需要一个Normal Map。我可以提供一个,但我们可以让Unity为我们完成工作。将高度贴图的纹理类型更改为法线贴图。Unity 会自动切换纹理以使用三线性过滤,并假设我们要使用灰度图像数据来生成法线贴图。这正是我们想要的,但将Bumpiness更改为更低的值,如0.05。

应用导入设置后,Unity 将计算法线贴图。原始高度地图仍然存在,但 Unity 在内部使用生成的地图。

  就像我们将法线可视化为颜色时所做的那样,必须调整它们以适合0-1范围。因此,它们被存储为(N + 1)/2。这表明平坦的区域将显示为浅绿色。但是,它们显示为浅蓝色。这是因为法线贴图最常见的约定是将向上方向存储在 Z 分量中。从 Unity 的角度来看,Y 和 Z 坐标是交换的。

2.1 Sampling the Normal Map

由于法线贴图与高度贴图完全不同,因此请相应地重命名着色器属性。

Properties {
		_Tint ("Tint", Color) = (1, 1, 1, 1)
		_MainTex ("Albedo", 2D) = "white" {}
		[NoScaleOffset] _NormalMap ("Normals", 2D) = "bump" {}
//		[NoScaleOffset] _HeightMap ("Heights", 2D) = "gray" {}
		[Gamma] _Metallic ("Metallic", Range(0, 1)) = 0
		_Smooth

我们可以删除所有高度贴图代码,并将其替换为单个纹理示例,然后进行归一化。

sampler2D _NormalMap;

//sampler2D _HeightMap;
//float4 _HeightMap_TexelSize;

…

void InitializeFragmentNormal(inout Interpolators i) {
	i.normal = tex2D(_NormalMap, i.uv).rgb;
	i.normal = normalize(i.normal);
}

当然,我们必须通过计算 2N-1 将法线转换回其原始 −1–1 范围。

	i.normal = tex2D(_NormalMap, i.uv).xyz * 2 - 1;

另外,请确保交换 Y 和 Z。

i.normal = tex2D(_NormalMap, i.uv).xyz * 2 - 1;
	i.normal = i.normal.xzy;

2.2 DXT5nm

我们的正常情况肯定有问题。这是因为Unity最终以不同于我们预期的方式对法线进行编码。尽管纹理预览显示RGB编码,但Unity实际上使用DXT5nm。 DXT5nm格式仅存储正常格式的X和Y分量。其 Z 分量将被丢弃。正如您所料,Y 分量存储在 G 通道中。但是,X 分量存储在 A 通道中。不使用 R 和 B 通道。\

Why store X and Y that way?

使用四通道纹理仅存储两个通道似乎是浪费的。使用未压缩纹理时,确实如此。DXT5nm格式的想法是它应该与DXT5纹理压缩一起使用。默认情况下,Unity 会执行此操作。 DXT5 通过对 4×4 像素的块进行分组,并用两种颜色和查找表近似它们来压缩像素。用于颜色的位数因通道而异。R 和 B 各得到五位,G 得到六位,A 得到八位。这就是将 X 坐标移动到 A 通道的原因之一。另一个原因是RGB通道有一个查找表,而A有自己的查找表。这样可以使 X 和 Y 组件保持隔离。

  压缩是有损的,但对于法线贴图是可以接受的。与未压缩的 8 位 RGB 纹理相比,您可以获得 3:1 的压缩比。 Unit 使用 DXT5nm 格式对所有法线贴图进行编码,无论您是否实际压缩它们。但是,在面向移动平台时并非如此,因为它们不支持 DXT5。在这些情况下,Unity 将使用常规 RGB 编码。


因此,当使用DXT5nm时,我们只能检索法线的前两个分量。

i.normal.xy = tex2D(_NormalMap, i.uv).wy * 2 - 1;

我们必须从其他两个组件中推断出第三个组件。因为法线是单位向量

i.normal.xy = tex2D(_NormalMap, i.uv).wy * 2 - 1;
	i.normal.z = sqrt(1 - dot(i.normal.xy, i.normal.xy));
	i.normal = i.normal.xzy;

从理论上讲,结果应等于原始 Z 分量。但是,由于纹理的精度有限,并且由于纹理过滤,因此结果通常会有所不同。不过,它足够近了。 此外,由于精度限制, Nx2 + Ny2 最终可能会超出范围。通过夹紧点积来确保不会发生这种情况。

	i.normal.z = sqrt(1 - saturate(dot(i.normal.xy, i.normal.xy)));

2.3 Scaling Bumpiness

由于我们将法线烘焙到纹理中,因此无法在片段着色器中缩放它们。或者我们可以吗? 我们可以在计算 Z 之前缩放法线的 X 和 Y 分量。如果我们减少X和Y,那么Z会变大,从而产生更平坦的表面。如果我们增加它们,情况会相反。因此,我们可以通过这种方式调整Bumpiness。由于我们已经在夹紧 X 和 Y 的平方,因此我们永远不会得到无效的法线。 让我们向着色器添加一个凹凸比例属性,就像 Unity 的标准着色器一样。

Properties {
		_Tint ("Tint", Color) = (1, 1, 1, 1)
		_MainTex ("Albedo", 2D) = "white" {}
		[NoScaleOffset] _NormalMap ("Normals", 2D) = "bump" {}
		_BumpScale ("Bump Scale", Float) = 1
		[Gamma] _Metallic ("Metallic", Range(0, 1)) = 0
		_Smoothness ("Smoothness", Range(0, 1)) = 0.1
	}

将此刻度纳入我们的正常计算中。

sampler2D _NormalMap;
float _BumpScale;

…

void InitializeFragmentNormal(inout Interpolators i) {
	i.normal.xy = tex2D(_NormalMap, i.uv).wy * 2 - 1;
	i.normal.xy *= _BumpScale;
	i.normal.z = sqrt(1 - saturate(dot(i.normal.xy, i.normal.xy)));
	i.normal = i.normal.xzy;
	i.normal = normalize(i.normal);
}

要获得与使用高度图时大致相同的强度的凸起,请将比例减小到0.25左右。

UnityStandardUtils 包含 UnpackScaleNormal 函数。它会自动对法线贴图使用正确的解码,并缩放法线。因此,让我们利用这个方便的功能。

void InitializeFragmentNormal(inout Interpolators i) {
//	i.normal.xy = tex2D(_NormalMap, i.uv).wy * 2 - 1;
//	i.normal.xy *= _BumpScale;
//	i.normal.z = sqrt(1 - saturate(dot(i.normal.xy, i.normal.xy)));
	i.normal = UnpackScaleNormal(tex2D(_NormalMap, i.uv), _BumpScale);
	i.normal = i.normal.xzy;
	i.normal = normalize(i.normal);
}

What does UnpackScaleNormal look like?

Unity 在定位不支持 DXT5nm 的平台时定义了UNITY_NO_DXT5nm关键字。在这种情况下,该函数将切换到 RGB 格式,并且不支持正常缩放。由于指令限制,在面向着色器模型 2 时,它也不支持缩放。因此,在定位手机时,不要依赖凸块比例。

half3 UnpackScaleNormal (half4 packednormal, half bumpScale) {
	#if defined(UNITY_NO_DXT5nm)
		return packednormal.xyz * 2 - 1;
	#else
		half3 normal;
		normal.xy = (packednormal.wy * 2 - 1);
		#if (SHADER_TARGET >= 30)
			// SM2.0: instruction count limitation
			// SM2.0: normal scaler is not supported
			normal.xy *= bumpScale;
		#endif
		normal.z = sqrt(1.0 - saturate(dot(normal.xy, normal.xy)));
		return normal;
	#endif
}

2.4 Combining Albedo and Bumps

现在我们有了一个功能正常的映射,你可以检查它所产生的差异。当仅使用大理石反照率纹理时,我们的四边形看起来像完美抛光的石头。添加法线贴图,它将成为一个更有趣的表面。

三,Bump Details

在第 3 部分“组合纹理”中,我们创建了一个具有细节纹理的着色器。我们用反照率做到了这一点,但我们也可以用颠簸来做到这一点。首先,将对细节反照率的支持添加到“我的第一个照明着色器”中。

Properties {
		_Tint ("Tint", Color) = (1, 1, 1, 1)
		_MainTex ("Albedo", 2D) = "white" {}
		[NoScaleOffset] _NormalMap ("Normals", 2D) = "bump" {}
		_BumpScale ("Bump Scale", Float) = 1
		[Gamma] _Metallic ("Metallic", Range(0, 1)) = 0
		_Smoothness ("Smoothness", Range(0, 1)) = 0.1
		_DetailTex ("Detail Texture", 2D) = "gray" {}
	}

与其为细节 UV 添加插值器,不如将主 UV 和细节 UV 手动打包到单个插值器中。主要UV以XY为单位,细节UV以ZW为单位。

struct Interpolators {
	float4 position : SV_POSITION;
//	float2 uv : TEXCOORD0;
	float4 uv : TEXCOORD0;
	float3 normal : TEXCOORD1;
	float3 worldPos : TEXCOORD2;

	#if defined(VERTEXLIGHT_ON)
		float3 vertexLightColor : TEXCOORD3;
	#endif
};

添加所需的变量并在顶点程序中填充插值器。

sampler2D _MainTex, _DetailTex;
sampler2D _MainTex, _DetailTex;
float4 _MainTex_ST, _DetailTex_ST;

…

Interpolators MyVertexProgram (VertexData v) {
	Interpolators i;
	i.position = mul(UNITY_MATRIX_MVP, v.position);
	i.worldPos = mul(unity_ObjectToWorld, v.position);
	i.normal = UnityObjectToWorldNormal(v.normal);
	i.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
	i.uv.zw = TRANSFORM_TEX(v.uv, _DetailTex);
	ComputeVertexLightColor(i);
	return i;
}

现在我们应该使用i.uv.xy而不是i.uv,当我们需要主UV时。

void InitializeFragmentNormal(inout Interpolators i) {
	i.normal = UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);
	i.normal = i.normal.xzy;
	i.normal = normalize(i.normal);
}

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
	InitializeFragmentNormal(i);

	float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);

	float3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _Tint.rgb;

	…
}

将细节纹理分解为反照率

float3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _Tint.rgb;
	albedo *= tex2D(_DetailTex, i.uv.zw) * unity_ColorSpaceDouble;

3.1 Detail Normals

由于大理石材质的细节纹理是灰度的,因此我们可以使用它来生成法线贴图。复制它并将其导入类型更改为法线贴图。将其颠簸度降低到类似 0.1 的水平,并保持所有其他设置不变。 当我们淡出 mipmap 时,颜色会淡出为灰色。因此,Unity 生成的详细法线贴图将淡化为平整。所以他们一起淡出。

将详细法线贴图的属性添加到着色器。给它一个凹凸比例

Properties {
		_Tint ("Tint", Color) = (1, 1, 1, 1)
		_MainTex ("Albedo", 2D) = "white" {}
		[NoScaleOffset] _NormalMap ("Normals", 2D) = "bump" {}
		_BumpScale ("Bump Scale", Float) = 1
		[Gamma] _Metallic ("Metallic", Range(0, 1)) = 0
		_Smoothness ("Smoothness", Range(0, 1)) = 0.1
		_DetailTex ("Detail Texture", 2D) = "gray" {}
		[NoScaleOffset] _DetailNormalMap ("Detail Normals", 2D) = "bump" {}
		_DetailBumpScale ("Detail Bump Scale", Float) = 1
	}

添加所需的变量并获取详细的法线贴图,就像主法线贴图一样。在组合它们之前,请仅显示正常细节。

sampler2D _NormalMap, _DetailNormalMap;
float _BumpScale, _DetailBumpScale;

…

void InitializeFragmentNormal(inout Interpolators i) {
	i.normal = UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);
	i.normal =
		UnpackScaleNormal(tex2D(_DetailNormalMap, i.uv.zw), _DetailBumpScale);
	i.normal = i.normal.xzy;
	i.normal = normalize(i.normal);
}

3.2 Blending Normals

我们将主反照率和细节反照率相乘在一起。我们不能用法线这样做,因为它们是向量。但是我们可以在归一化之前对它们进行平均。

void InitializeFragmentNormal(inout Interpolators i) {
	float3 mainNormal =
		UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);
	float3 detailNormal =
		UnpackScaleNormal(tex2D(_DetailNormalMap, i.uv.zw), _DetailBumpScale);
	i.normal = (mainNormal + detailNormal) * 0.5;
	i.normal = i.normal.xzy;
	i.normal = normalize(i.normal);
}

结果不是很好。主要凸起和细节凸起都变得平坦。理想情况下,当其中一个是平坦的时,它根本不应该影响另一个。 我们在这里有效地尝试做的是将两个高度场结合起来。平均这些是没有意义的。添加它们更有意义。添加两个高度函数时,也会添加它们的斜率(因此它们的导数)。我们可以从正态中提取衍生物吗?

我们可以通过归一化,来构造我们自己得正态向量,法线映射包含相同类型的法线,这意味着我们可以通过将X和Y除以Z来找到偏导数。仅当 Z 为零(对应于垂直曲面)时,此操作才会失败。我们的颠簸远没有那么陡峭,所以我们不必担心这一点。 一旦我们有了导数,我们就可以将它们相加以找到总和高度场的导数。然后,我们转换回正态向量

void InitializeFragmentNormal(inout Interpolators i) {
	float3 mainNormal =
		UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);
	float3 detailNormal =
		UnpackScaleNormal(tex2D(_DetailNormalMap, i.uv.zw), _DetailBumpScale);
	i.normal =
		float3(mainNormal.xy / mainNormal.z + detailNormal.xy / detailNormal.z, 1);
	i.normal = i.normal.xzy;
	i.normal = normalize(i.normal);
}

Why is it known as whiteout blending?

这种方法首先由Christopher Oat在SIGGRAPH'07上公开描述。它被用于AMD的Ruby:Whiteout演示中,因此得名。

i.normal =
		float3(mainNormal.xy + detailNormal.xy, mainNormal.z * detailNormal.z);
//		float3(mainNormal.xy / mainNormal.z + detailNormal.xy / detailNormal.z, 1);

UnityStandardUtils包含BlendNormals函数,该函数还使用白化混合。所以让我们来看看这个函数。它还使结果正常化,因此我们不必再自己这样做。

i.normal = BlendNormals(mainNormal, detailNormal);
//		float3(mainNormal.xy + detailNormal.xy, mainNormal.z * detailNormal.z);
	i.normal = i.normal.xzy;
//	i.normal = normalize(i.normal);

What does BlendNormals look like?

half3 BlendNormals (half3 n1, half3 n2) {
	return normalize(half3(n1.xy + n2.xy, n1.z * n2.z));
}

四,Tangent Space

到目前为止,我们已经假设我们正在遮罩与 XZ 平面对齐的平面。但是,要使这种技术有任何用处,它必须适用于任意几何图形。

立方体的一个面可以对齐,使其符合我们的假设。我们可以通过交换和翻转尺寸来支持其他侧面。但这假设一个立方体是轴对齐的。当立方体具有任意旋转时,它将变得更加复杂。我们必须转换凹凸贴图代码的结果,使其与人脸的实际方向相匹配。 我们能知道一张脸的方向吗?为此,我们需要定义 u 轴和 V 轴的向量。这两者,加上法向量,定义了一个与我们的假设相匹配的3D空间。一旦我们有了这个空间,我们就可以用它来将颠簸转化为世界空间。

  由于我们已经有了正态向量 N,因此我们只需要一个额外的向量。这两个向量的交叉乘积定义了第三个向量。 附加向量作为网格顶点数据的一部分提供。由于它位于由曲面法线定义的平面中,因此称为切向量 T。按照惯例,此矢量与指向右侧的 U 轴匹配。 第三个向量称为 B、二正切或双正交向量。正如Unity将其称为双正态一样,我也会这样做。此矢量定义 V 轴,指向前方。推导比特切的标准方法是通过 B = N × T。但是,这将产生一个指向后方而不是向前方的向量。为了纠正这一点,结果必须乘以−1。此因子存储为 T的额外第四个分量。

Why store −1 in the tangent vector?

创建具有双边对称性的3D模型(如人和动物)时,一种常用技术是左右镜像网格。这意味着您只需要编辑网格的一侧。而且,您只需要原本需要的纹理数据的一半。这意味着法向量和切向量也会被镜像。但是,不应镜像双正体!为了支持这一点,镜像切线将 1 存储在其第四个分量中,而不是 −1。所以这些数据实际上是可变的。这就是为什么必须明确提供它的原因。


因此,我们可以使用顶点法线和切线来构建与网格表面匹配的 3D 空间。此空间称为切空间、切基或 TBN 空间。对于立方体,每个面的切空间是均匀的。对于球体,切线空间环绕其表面。 为了构造此空间,网格必须包含切向量。幸运的是,Unity 的默认网格包含此数据。将网格导入 Unity 时,您可以导入自己的切线,也可以让 Unity 为您生成切线。

4.1 Visualizing Tangent Space

为了了解切线空间的工作原理,让我们对它进行快速可视化。使用 OnDrawGizmos 方法创建 TangentSpaceVisualizer 组件

using UnityEngine;

public class TangentSpaceVisualizer : MonoBehaviour {

	void OnDrawGizmos () {
	}
}

每次绘制小控件时,从游戏对象的网格滤镜中获取网格,并使用它来显示其切线空间。当然,这只有在实际存在网格时才有效。抓住阴影网格,而不是网格。第一个为我们提供了对网格资产的引用,而第二个将创建一个副本。

Why does the MeshFilter.mesh property create a copy?

假设您有一个使用网格资产的游戏对象。您希望在运行时仅调整该游戏对象的网格。然后,您要创建网格资源的本地副本,特定于该对象。这就是MeshFilter.mesh创建副本的原因。

void OnDrawGizmos () {
		MeshFilter filter = GetComponent<MeshFilter>();
		if (filter) {
			Mesh mesh = filter.sharedMesh;
			if (mesh) {
				ShowTangentSpace(mesh);
			}
		}
	}

	void ShowTangentSpace (Mesh mesh) {
	}

首先,我们将展示正态向量。从网格中获取顶点位置和法线,并使用它们绘制线条。我们必须将它们转换为世界空间,以便它们与场景中的几何图形相匹配。由于法线与切线空间中的向上方向相对应,因此让我们给它们一个绿色。

void ShowTangentSpace (Mesh mesh) {
		Vector3[] vertices = mesh.vertices;
		Vector3[] normals = mesh.normals;
		for (int i = 0; i < vertices.Length; i++) {
			ShowTangentSpace(
				transform.TransformPoint(vertices[i]),
				transform.TransformDirection(normals[i])
			);
		}
	}

	void ShowTangentSpace (Vector3 vertex, Vector3 normal) {
		Gizmos.color = Color.green;
		Gizmos.DrawLine(vertex, vertex + normal);
	}

Isn't it inefficient to retrieve the mesh data every time?

由于这只是一个快速可视化,因此我们无需费心优化它。


将此组件添加到具有网格的某些对象,以查看其顶点法线。

什么是合理的线条长度?这取决于几何形状。因此,让我们添加一个可配置的比例。我们还支持可配置的偏移,这会将线推离曲面。这样可以更轻松地检查重叠的顶点。

public float offset = 0.01f;
	public float scale = 0.1f;

	void ShowTangentSpace (Vector3 vertex, Vector3 normal) {
		vertex += normal * offset;
		Gizmos.color = Color.green;
		Gizmos.DrawLine(vertex, vertex + normal * scale);
	}

现在包括切向量。它们的工作方式与普通矢量一样,只是它们是4D矢量。当它们指向本地的右方向时,给它们一个红色。

void ShowTangentSpace (Mesh mesh) {
		Vector3[] vertices = mesh.vertices;
		Vector3[] normals = mesh.normals;
		Vector4[] tangents = mesh.tangents;
		for (int i = 0; i < vertices.Length; i++) {
			ShowTangentSpace(
				transform.TransformPoint(vertices[i]),
				transform.TransformDirection(normals[i]),
				transform.TransformDirection(tangents[i])
			);
		}
	}

	void ShowTangentSpace (Vector3 vertex, Vector3 normal, Vector3 tangent) {
		vertex += normal * offset;
		Gizmos.color = Color.green;
		Gizmos.DrawLine(vertex, vertex + normal * scale);
		Gizmos.color = Color.red;
		Gizmos.DrawLine(vertex, vertex + tangent * scale);
	}

最后,用蓝线构造并显示双正向量。

void ShowTangentSpace (Mesh mesh) {
		…
		for (int i = 0; i < vertices.Length; i++) {
			ShowTangentSpace(
				transform.TransformPoint(vertices[i]),
				transform.TransformDirection(normals[i]),
				transform.TransformDirection(tangents[i]),
				tangents[i].w
			);
		}
	}

	void ShowTangentSpace (
		Vector3 vertex, Vector3 normal, Vector3 tangent, float binormalSign
	) {
		…
		Vector3 binormal = Vector3.Cross(normal, tangent) * binormalSign;
		Gizmos.color = Color.blue;
		Gizmos.DrawLine(vertex, vertex + binormal * scale);
	}

您可以看到,对于默认立方体的每个面,切线空间是差的,但保持不变。对于默认球体,每个顶点的切线空间是不同的。因此,切线空间将跨三角形进行插值,从而形成弯曲的空间。

  将切线空间包裹在球体周围是有问题的。Unity 的默认球体使用经纬度纹理布局。这就像把一张纸缠绕在一个球上,形成一个圆柱体。然后,圆柱体的顶部和底部被揉皱,直到它们与球体匹配。所以电线杆很乱。Unity 的默认球体将其与三次顶点布局相结合,这加剧了问题。它们适用于模型,但不要指望默认网格能够产生高质量的结果。

4.2 Tangent Space in the Shader

要访问着色器中的切线,我们必须将它们添加到顶点数据结构中。

struct VertexData {
	float4 position : POSITION;
	float3 normal : NORMAL;
	float4 tangent : TANGENT;
	float2 uv : TEXCOORD0;
};

我们必须将它们作为附加插值器包括在内。插值器的顺序并不重要,但我喜欢将正态和切线保持在一起。

struct Interpolators {
	float4 position : SV_POSITION;
	float4 uv : TEXCOORD0;
	float3 normal : TEXCOORD1;
	float4 tangent : TEXCOORD2;
	float3 worldPos : TEXCOORD3;

	#if defined(VERTEXLIGHT_ON)
		float3 vertexLightColor : TEXCOORD4;
	#endif
};

在顶点程序中,使用 UnityCG 中的 UnityObjectToWorldDir 将切线转换为世界空间。当然,这只适用于切线的 XYZ 部分。它的 W 分量需要不加修改地传递。

Interpolators MyVertexProgram (VertexData v) {
	Interpolators i;
	i.position = mul(UNITY_MATRIX_MVP, v.position);
	i.worldPos = mul(unity_ObjectToWorld, v.position);
	i.normal = UnityObjectToWorldNormal(v.normal);
	i.tangent = float4(UnityObjectToWorldDir(v.tangent.xyz), v.tangent.w);
	i.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
	i.uv.zw = TRANSFORM_TEX(v.uv, _DetailTex);
	ComputeVertexLightColor(i);
	return i;
}

What does UnityObjectToWorldDir look like?

它只是一个方向变换,使用世界到对象矩阵。

// Transforms direction from object to world space
inline float3 UnityObjectToWorldDir( in float3 dir ) {
    return normalize(mul((float3x3)unity_ObjectToWorld, dir));
}

现在,我们可以访问片段着色器中的正线和切线。因此,我们可以在 InitializeFragmentNormal 中构造二元。但是,我们必须注意不要用颠簸的法线替换原来的法线。凸起法线存在于切线空间中,因此请将其分开。

void InitializeFragmentNormal(inout Interpolators i) {
	float3 mainNormal =
		UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);
	float3 detailNormal =
		UnpackScaleNormal(tex2D(_DetailNormalMap, i.uv.zw), _DetailBumpScale);
	float3 tangentSpaceNormal = BlendNormals(mainNormal, detailNormal);
	tangentSpaceNormal = tangentSpaceNormal.xzy;

	float3 binormal = cross(i.normal, i.tangent.xyz) * i.tangent.w;
}

Shouldn't we normalize the normal and tangent vectors?

如果我们想确保我们使用的是单位向量,那么我们确实应该这样做。实际上,要创建适当的3D空间,我们还应该确保法线和切线之间的角度是90°。 但是,我们不会为此烦恼。您将在下一节中找出原因。


现在我们可以将凸起的法线从切线空间转换为世界空间。

float3 binormal = cross(i.normal, i.tangent.xyz) * i.tangent.w;

	i.normal = normalize(
		tangentSpaceNormal.x * i.tangent +
		tangentSpaceNormal.y * i.normal +
		tangentSpaceNormal.z * binormal
	);

我们还可以摆脱显式的YZ交换,将其与空间转换相结合。

//	tangentSpaceNormal = tangentSpaceNormal.xzy;
	
	float3 binormal = cross(i.normal, i.tangent.xyz) * i.tangent.w;

	i.normal = normalize(
		tangentSpaceNormal.x * i.tangent +
		tangentSpaceNormal.y * binormal +
		tangentSpaceNormal.z * i.normal
	;

What other data does unity_WorldTransformParams contain?

我不到啊


4.3 Synched Tangent Space

当3D艺术家创建详细模型时,通常的方法是构建一个非常高分辨率的模型。所有细节都是实际的3D几何图形。为了在游戏中实现此功能,将生成模型的低分辨率版本。细节被烘焙到此模型的纹理中。 高分辨率模型的法线将烘焙到法线贴图中

  这是通过将法线从世界空间转换为切线空间来完成的。在游戏中渲染低分辨率模型时,此转换是相反的。 此过程工作正常,只要两个转换使用相同的算法和切空间。当他们不这样做时,游戏内的结果将是错误的。这可能会给3D艺术家带来很多悲伤。因此,您必须确保法线贴图生成器、Unity 的网格导入过程和着色器都已同步。这称为同步切线空间工作流。

What about our normal maps?

我们从高度字段生成了法线贴图。因此,它们具有平坦的参考系,并且它们的切线空间是规则的。因此,当它们应用于具有弯曲切线空间的对象时,与高度场相比,最终法线将被扭曲。这很好,因为大理石的确切外观并不重要。


从版本5.3开始,Unity使用mikktspace。因此,请确保在生成法线贴图时也使用mikktspace。导入网格时,您可以允许 Unity 为您生成切线向量,因为它使用 mikktspace 算法。或者,自己导出mikktspace切线,并让Unity使用它们。

What is mikktspace?

它是切空间和法向生成的标准,由Morten Mikkelsen创建。这个名字是Mikkelsen切线空间的简写。 对于着色器,通过与mikktspace同步,它必须在顶点程序中接收规范化的正态和切向量。然后对这些向量进行插值,而不是对每个片段进行重新规范化。二正则通过计算 cross(normal.xyz, tangent.xyz) * tangent.w 找到。因此,我们的着色器与mikktspace同步,Unity的标准着色器也是如此。 请注意,mikktspace不能保证是规则的。法线和切线之间的角度可以自由变化。这不是问题,只要失真不会变得太大。因为我们只用它来转换法线,所以一致性才是最重要的。


使用mikktspace时,有一个选择。双正体既可以在片段程序中构造- 就像我们一样 - 也可以在顶点程序中构造 - 就像Unity一样。这两种方法产生略有不同的二次体。

因此,在为 Unity 生成法线地图时,请使用与计算每个顶点的二正态相对应的设置。或者继续假设它们是按片段计算的,并使用着色器来执行此操作。

Tangent space is a hassle, can we make do without it?

由于切线空间环绕对象的表面,因此对象的确切形状无关紧要。您可以对其应用任何切空间法线贴图。您也可以平铺地图,就像我们所做的那样。此外,当网格由于动画而变形时,切线空间(以及法线贴图)也会随之变形。

  如果取消切线空间,则必须使用对象空间法线贴图。这些地图不会粘在表面上。因此,它们不能平铺,不能应用于不同的形状,也不能与网格一起变形。此外,它们不能很好地与纹理压缩一起使用。

  因此,有很好的理由使用切线空间。话虽如此,还有一些方法可以使用切空间法线,而无需显式提供切向量。此类技术依赖于着色器派生指令,我们将在以后的教程中介绍这些指令。但这并不能消除对同步工作流程的需求。

4.4 Binormals Per Vertex or Fragment

如果我们想与 Unity 的标准着色器保持一致,我们必须计算每个顶点的二正态。这样做的好处是,我们不必在片段着色器中计算交叉积。缺点是我们需要一个额外的插值器。 如果您不确定要使用哪种方法,则始终可以同时支持这两种方法。

  假设定义了BINORMAL_PER_FRAGMENT,我们计算每个片段的二正态。否则,我们按顶点执行此操作。在前一种情况下,我们保留 float4 切线插值器。在后者中,我们需要两个 float3 插值器。

struct Interpolators {
	float4 position : SV_POSITION;
	float4 uv : TEXCOORD0;
	float3 normal : TEXCOORD1;

	#if defined(BINORMAL_PER_FRAGMENT)
		float4 tangent : TEXCOORD2;
	#else
		float3 tangent : TEXCOORD2;
		float3 binormal : TEXCOORD3;
	#endif

	float3 worldPos : TEXCOORD4;

	#if defined(VERTEXLIGHT_ON)
		float3 vertexLightColor : TEXCOORD5;
	#endif
};

Does that mean we skip an interpolator?

我们只在需要二次插值器时才使用TEXCOORD3。因此,当定义BINORMAL_PER_FRAGMENT时,我们跳过此插值器索引。这很好,我们可以使用我们想要的任何插值器索引,直到最大值。


让我们将双正态计算放在它自己的函数中。然后,我们可以在顶点或片段着色器中使用它。

float3 CreateBinormal (float3 normal, float3 tangent, float binormalSign) {
	return cross(normal, tangent.xyz) *
		(binormalSign * unity_WorldTransformParams.w);
}

Interpolators MyVertexProgram (VertexData v) {
	Interpolators i;
	i.position = mul(UNITY_MATRIX_MVP, v.position);
	i.worldPos = mul(unity_ObjectToWorld, v.position);
	i.normal = UnityObjectToWorldNormal(v.normal);

	#if defined(BINORMAL_PER_FRAGMENT)
		i.tangent = float4(UnityObjectToWorldDir(v.tangent.xyz), v.tangent.w);
	#else
		i.tangent = UnityObjectToWorldDir(v.tangent.xyz);
		i.binormal = CreateBinormal(i.normal, i.tangent, v.tangent.w);
	#endif
		
	i.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
	i.uv.zw = TRANSFORM_TEX(v.uv, _DetailTex);
	ComputeVertexLightColor(i);
	return i;
}

…

void InitializeFragmentNormal(inout Interpolators i) {
	float3 mainNormal =
		UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);
	float3 detailNormal =
		UnpackScaleNormal(tex2D(_DetailNormalMap, i.uv.zw), _DetailBumpScale);
	float3 tangentSpaceNormal = BlendNormals(mainNormal, detailNormal);

	#if defined(BINORMAL_PER_FRAGMENT)
		float3 binormal = CreateBinormal(i.normal, i.tangent.xyz, i.tangent.w);
	#else
		float3 binormal = i.binormal;
	#endif
	
	i.normal = normalize(
		tangentSpaceNormal.x * i.tangent +
		tangentSpaceNormal.y * binormal +
		tangentSpaceNormal.z * i.normal
	);
}

 

由于BINORMAL_PER_FRAGMENT未在任何地方定义,因此我们的着色器现在将计算每个顶点的二正统。如果要按片段计算它们,则必须在某个位置定义BINORMAL_PER_FRAGMENT。您可以将其视为包含文件的配置选项。因此,在包含“我的照明”之前,在“我的第一个照明着色器”中定义它是有意义的。 由于对所有刀路使用相同的设置是有意义的,因此我们必须在基刀和加法刀路中定义它。但是我们也可以把它放在着色器顶部的CGINCLUDE块中。该块的内容包含在所有CGPROGRAM块中。

Properties {
		…
	}

	CGINCLUDE

	#define BINORMAL_PER_FRAGMENT

	ENDCG

	SubShader {
		…
	}

您可以通过检查已编译的着色器代码来验证这是否有效。例如,下面是 D3D11 使用的插值器,未定义BINORMAL_PER_FRAGMENT。

// Output signature:
//
// Name                 Index   Mask Register SysValue  Format   Used
// -------------------- ----- ------ -------- -------- ------- ------
// SV_POSITION              0   xyzw        0      POS   float   xyzw
// TEXCOORD                 0   xyzw        1     NONE   float   xyzw
// TEXCOORD                 1   xyz         2     NONE   float   xyz 
// TEXCOORD                 2   xyz         3     NONE   float   xyz 
// TEXCOORD                 3   xyz         4     NONE   float   xyz 
// TEXCOORD                 4   xyz         5     NONE   float   xyz 

当定义BINORMAL_PER_FRAGMENT时,它们就在这里。

// Output signature:
//
// Name                 Index   Mask Register SysValue  Format   Used
// -------------------- ----- ------ -------- -------- ------- ------
// SV_POSITION              0   xyzw        0      POS   float   xyzw
// TEXCOORD                 0   xyzw        1     NONE   float   xyzw
// TEXCOORD                 1   xyz         2     NONE   float   xyz 
// TEXCOORD                 2   xyzw        3     NONE   float   xyzw
// TEXCOORD                 4   xyz         4     NONE   float   xyz 

 

posted @ 2022-06-17 11:07  Naxts  阅读(340)  评论(0编辑  收藏  举报