Post

Unity 纹理高级用法(翻译六)

Unity 纹理高级用法(翻译六)

本篇摘要:

  • 扰动法线以模拟凹凸视觉
  • 从高度场计算法线
  • 采样和混合法线贴图
  • 从切线空间转换到世界空间

Bump Map(凹凸贴图):

Bump Map TypeDescribe
NormalMap法线图,映射公式:normal=pixel*2-1,反映射:pixel=(normal+1)/2. 法线存储既可以在
模型空间,也可以在切线空间。//unity顶点输入结构带切线变量,一般存在切线空间更佳
HeightMap灰度图(黑白纹理-强度值),颜色越浅该表面越向外凸起,颜色深越凹。视差映射技术,与
Occlusion Map搭配使用体验更佳,计算昂贵
Occlusion Map灰度图,表面细节更丰富。颜色值白色表示应该接收完全间接照明的区域,黑色表示没有
间接照明。如裂缝或褶皱,实际上不会接收到太多的间接光,可与高度图一起使用
Detail Maps参考StandardShader:第二细节纹理,应用第二反照率图和第二法线图,在近距离观察时
有清晰的细节,比如毛孔、细小的裂缝等。计算昂贵

常用纹理
NormalMap 法线纹理:比较常用
HeightMap 高度纹理(视差映射):手机平台不常用,使用法线纹理替代
Occlusion Map:细节纹理
Secondary Maps (Detail Maps) & Detail Mask:细节纹理

高度图-HeightMap

高度图为了模拟平面的凹凸程度,将高度(黑白色)数据存储在纹理中,由于纹理数据是二维的,即u轴和v轴,那为了得到这些数据为每个片段生成法向量,可分别在u轴和v轴上采样。先从U轴计算$ f(u)=h $ ,如果知道了斜率就能求得u轴上所有点的法向量,但斜率由h的变化程度高低决定。为了近似得到从一个点到下一个点的高度差:

斜率采样示意图,从$f(0) \rightarrow f(1)$
斜率采样示意图,从$f(0) \rightarrow f(1)$

这是对切向量的一个粗略的估算,它把整张纹理作为线性的斜率。那为了避免这种粗略计算,可以采样两个靠的更近点的。例如,从$ 0 \rightarrow {1\over2} $,那这两点的斜率$ f=f({1 \over 2})-f(0) $,同时f因子被缩小,需要乘以相应的倍数。$ 2f({1 \over 2})-f(0) $。扩展开来,可以得到如下的函数:δ值越小越精确,必须大于0小于1.

差分函数:

\[f^`(u) = {f(u+δ) - f(u) \over δ}\]

有限差分函数:

\[f^`(u)= lim_{(δ\rightarrow 0)} {f(u+δ) - f(u) \over δ}\]

那么切向量就是 \(\begin{bmatrix} 1\\ f`(u)\\ 0 \end{bmatrix}^\mathrm{T}\) ,从切向量计算法向量 \(\begin{bmatrix} f’(u)\\ 1\\ 0 \end{bmatrix}^\mathrm{T}\)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sampler2D _HeightMap;
float4 _HeightMap_TexelSize;//xy是纹素坐标(uv),zw是整张纹理宽高
float2 delta = float2(_HeightMap_TexelSize.x, 0);//u轴
float h1 = tex2D(_HeightMap, i.uv);//模型uv在高度图采样
float h2 = tex2D(_HeightMap, i.uv + delta);//二次采样

//第一步套用公式
//i.normal = float3(1, (h2 - h1) / delta.x, 0); 

//第二步优化,缩放向量并不改变方向,消除了除法操作
//i.normal = float3( delta.x, (h2 - h1), 0);

//第三步改变垂直方向,需要得到法向量正向垂直于表面,那么逆时针旋转90度以翻转x分量符号.//Y是扰动法向量的高低变化因子
i.normal = float3( h1 - h2, 1 , 0);

i.normal = normalize(i.normal);

有限差分只在一个方向近似求值,为了更好近似可以在两个方向线性逼近.

中心差分:

\[f^`(u)= lim_{(δ\rightarrow 0)} {f(u+{δ\over 2}) - f(u - {δ\over 2}) \over δ}\]
1
2
3
4
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);

那么$f’(u, v)$计算$f’(v)$同理,切向量 \(\begin{bmatrix} 0\\ f’(v)\\ 1 \end{bmatrix}^\mathrm{T}\) 法向量是 \(\begin{bmatrix} 0\\ 1\\ f’(v) \end{bmatrix}^\mathrm{T}\)

1
2
3
4
5
6
7
8
9
10
11
12
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);//直接使用叉积求出垂直于u和v轴的法向量=>(0*(v2-v1)-(u2-u1)*1, 1*1-0*0, (u2-u1)*0-1*(v2-v1))=(u1-u2, 1, v1-v2)
i.normal = float3(u1 - u2, 1, v1 - v2);

i.normal = normalize(i.normal);

\(\begin{bmatrix} 0\\ f’(v)\\ 1 \end{bmatrix}\) x \(\begin{bmatrix} 1\\ f’(u)\\ 0 \end{bmatrix}\) = \(\begin{bmatrix} -f’(u)\\ 1\\ -f’(v) \end{bmatrix}\)

法线-Normal Map

高度图是每帧采样实时计算法线,为了避免高额计算量,采用预制法线纹理代替。

Unity中使用高度图
Unity中使用高度图

导入高度图作为法线贴图预先计算法线纹理必须勾选Create from Grayscale,白色表示相对更高,黑色表示相对更低。

像素分量范围是[0,1],而法线分量范围[-1,1]。相互映射转换公式为:

$ pixel = {(normal+1)\over 2} $

$ normal = pixel \cdot 2 – 1 $

法线纹理呈现淡蓝色,这是因为法向映射最常见的约定是将向上的方向存储在Z分量中(垂直于表面外侧),又由于DXT5nm纹理压缩格式的原因,只存储了X与Y分量舍弃了Z分量(Y分量存储在G通道,X分量存储在A通道,RB通道被舍弃)。通过推导法向量的单位向量可得Z分量:

\[|N| = |N|^2 = N_x{^2} + N_y{^2} + N_z{^2} = 1\] \[Nz = \sqrt{1 - N_x{^2} - N_y{^2}}\]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//第一种方法
// Unpack normal as DXT5nm (1, y, 1, x) or BC5 (x, y, 0, 1)
//dxt5压缩对应的位置取wy
i.normal.xy = tex2D(_NormalMap, i.uv).wy * 2 - 1;
i.normal.xy *= _BumpScale;//计算Z之前缩放才有效,平坦凹凸程度
i.normal.z = sqrt(1 - saturate(dot(i.normal.xy, i.normal.xy)));//dot模拟平方计算-((x,y)*(x,y))=-x方-y方
i.normal = i.normal.xzy;
i.normal = normalize(i.normal);

//第二种方法
//UnityStandardUtils.cginc包含了解码法线函数,替代上面的方法
i.normal = UnpackScaleNormal(tex2D(_NormalMap, i.uv), _BumpScale);
i.normal = i.normal.xzy;
i.normal = normalize(i.normal);

细节纹理与细节法线-Detail Maps(Second Texture) 与 Detail Normals

第二细节纹理与MainTexture合并,简要代码如下:

1
2
3
4
5
6
//顶点uv坐标映射到纹理uv
i.uv.xy  = TRANSFORM_TEX(v.uv, _MainTex);
i.uv.zw = TRANSFORM_TEX(v.uv, _DetailTex);
//计算第二纹理的影响
float3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _Tint.rgb;
albedo *= tex2D(_DetailTex, i.uv.zw) * unity_ColorSpaceDouble;//颜色空间转换

第二细节纹理的法线映射

1
2
3
4
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);

法线融合-Blending Normals

方式一:(main.normal + details.normal) * 0.5; 简单容易,但结果不是很好。主纹理和细节纹理都变得平坦。理想情况下,当其中一个是平的,期望它不会影响到另一个。

1
2
3
4
5
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);

方式二:用z分量做缩放因子求偏导函数,然后相加。[Mx, My, Mz]T = [Mx/Mz, My/Mz, 1]T 同理求得detail偏导函数,然后相加:[Mx/Mz + Dx/Dz, My/Mz + Dy/Dz, 1]T .效果很好,但是在合并陡峭时仍将失去细节。

1
2
3
4
5
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);

方式三:白色调和,对上一步合并法线分别乘以MzDz,然后再去掉x和y的缩放因子夸大缩放,使陡峭更加明显,同时平坦的法线,它不会影响到另一个了。

1
2
3
4
5
6
7
float3 mainNormal = UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);
float3 detailNormal = UnpackScaleNormal(tex2D(_DetailNormalMap, i.uv.zw), _DetailBumpScale);
i.normal = float3(mainNormal.xy + detailNormal.xy, mainNormal.z * detailNormal.z);
//UnityStandardUtils包含了混合函数
//i.normal = BlendNormals(mainNormal, detailNormal);
i.normal = i.normal.xzy;
i.normal = normalize(i.normal);View Code

切线空间-Tangent Space

切线空间的法线纹理:顶点为原点,z轴为法线方向,x轴为切线方向,y轴为垂直于xz的副切线方向。Unity导入模型计算切线默认使用了mikktspace(在顶点着色器计算),也可以在片元着色器计算cross得到副切线向量。

顶点下计算:

1
2
3
4
5
6
struct VertexData {
	float4 tangent : TANGENT;
};
struct Interpolators {
	float4 tangent : TEXCOORD2;
};

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

1
2
3
4
5
6
7
8
9
10
11
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;
}

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

1
2
3
4
5
6
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交换,将其与空间转换结合在一起。

1
2
3
4
5
6
7
8
//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
;

在构造副法线时,还有一个额外的细节。假设一个对象的scale设置为(- 1,1,1),这意味着它是镜像的。在这种情况下,我们必须翻转副法线,来正确地镜像切线空间。事实上,当奇数维数为负时,我们必须这样做。_UnityShaderVariables_通过定义float4 unity_WorldTransformParams变量来帮助我们完成这个任务。当需要翻转副法线时,它的第四个分量为- 1,否则为1。

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

转换空间

在世界空间下计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fixed4 MyFrag(v2f v) : SV_TARGET{
	//...
	float3 tangentSpaceNormal= UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);
	#if defined(BINORMAL_PER_FRAGMENT)
	    float3 binormal = cross(v.normal, v.tangent.xyz) * v.tangent.w;
	#else
	    float3 binormal = v.binormal;
	#endif
	//把切线空间转到世界空间
	//tangentSpaceNormal * [v.tangent,binromal, v.normal]T
	v.normal = normalize(
	    tangentSpaceNormal.x * v.tangent +
	    tangentSpaceNormal.y * binormal +
	    tangentSpaceNormal.z * v.normal
	);
	//...
}

在切线空间计算

1
2
3
4
5
//计算副切线
float3 binormal = cross(normalize(i.normal), normalize(i.tangent.xyz)) * i.tangent.w;
//切线空间矩阵//行优先的填充
float3x3 t_matrix = float3x3(i.tangent.xyz, binormal, i.normal);
//把各种信息转到切线空间下参与计算

副切线在哪算合适

在顶点计算不必计算叉乘函数,通过宏定义开启。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
};

如果不确定在哪里计算比较好,可以同时支持这两种方法。假设定义了BINORMAL_PER_FRAGMENT,我们逐像素计算每个片段的副法线。否则,逐顶点计算。在前一种情况下,我们保持我们的float4 tangent变量 。在后者中,我们需要两个float3变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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
	);
}

要对所有Pass块生效,需要使用CGINCLUDE … ENDCG包含

This post is licensed under CC BY 4.0 by the author.