这一篇参考 cat-likeCoding 渲染第六章
无
新建一个材质,调整我们的模型,换成一个平滑的 quad,然后调整摄像机。并且将环境光强度调整到 0.
接下来我们考虑,怎么让这个平面变得凹凸不平。
明显顶点法线是不够用的,我们需要一个方案,来修改逐像素的法线来处理
我们引入这样一张贴图
尝试用这张贴图来映射法线的变换。
1[NoScaleOffset]_HeightTex ("HeightMap", 2D) = "white" {}
前面的[NoScaleOffset]表示我们使用和 uv 坐标相同的纹理坐标,无需缩放或者偏移。
对于我们现在平面的法线,我们统一都是(0,1,0)因此,如果我们想要让法线变换,应该要在 xz 方向上做变换.
想象一下,高度贴图的颜色值代表高度,那么平面就是一个凹凸不平的,高度各有差异的坑坑洼洼的地形。
我们可以先对 u 和 v 分别求偏导来获得切向量
对于 uv 平面,我们首先尝试对 u 求偏导,获得在 u 方向上的偏导数
求偏导之前,我们要知道 δ 是什么,Unity 为我们提供了这样一个接口
1float4 _HeightTex_TexelSize;
它的前两个组件包含纹素大小,即 U 和 V 的分数。其他两个组件包含像素量。例如,对于 256×128 纹理,它将包含 (0.00390625, 0.0078125, 256, 128)
接下来我们来求 uv 平面上对 u 的偏导,来得到 x 方向上的切向量
1float fxy = SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, i.uv).rgb;
2float delta = _HeightTex_TexelSize.x;
3float fx_delta = SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x+delta,i.uv.y)).rgb;
4float3 tangle_x = float3(1,(fx_delta-fxy)/delta,0);
事实上我们切向量的长度并不关键,因此我们可以改成这样,这样没有除法我们的精度能高一些
1float3 tangle_x = float3(delta,fx_delta-fxy,0);
然后,我们将这个切向量绕着 z 轴旋转 90 度就可以得到对应的法向量:
可以通过交换矢量的 X 和 Y 分量,并翻转新 X 分量的符号,将 2D 矢量逆时针旋转 90°。所以我们最终得到
1float3 normal = float3(fxy-fx_delta, delta, 0)
我们得到了一坨看起来黑乎乎的东西
思其原因,是因为我们的 Delta 定得实在是太小了,以 256*256 的分辨率距离,我们的 Delta 几乎就在 1/256 这个级别。
因此我们需要让这个切线平滑一些,因为我们只是想要它微微移动一下即可,因此我们直接将整体的偏导数乘以一个缩放倍数,最后归一化的结果是这样
1float3 tangle_x = float3(1,(fx_delta-fxy),0);
这时候我们得到的是这样的
嗯,似乎有一点点形状出来了!
但是明显这个不太完整:我们有 u 和 v 两个方向上的变化分量,但是我们只考虑了 u 方向上的分量,而没有考虑 v 方向上的分量,因此我们需要同样计算 v 方向上的偏导,然后将两个偏导出来的切线叉乘,就得到了比较准确的法向量了!
1float fx_delta = SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x+_HeightTex_TexelSize.x*0.5,i.uv.y)).rgb
2- SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x-_HeightTex_TexelSize.x*0.5,i.uv.y)).rgb;
3float3 tangle_x = float3(1,fx_delta,0);
4
5float fz_delta = SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x,i.uv.y+_HeightTex_TexelSize.y*0.5)).rgb
6- SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x,i.uv.y-_HeightTex_TexelSize.y*0.5)).rgb;
7float3 tangle_z = float3(0,fz_delta,1);
8
9i.normal = cross(tangle_z,tangle_x);
10i.normal = normalize(i.normal);
然后得到的结果就很不错了,似乎
(说实话,看起来似乎没什么区别)
好的,到了这一步,我们稍微用我们的头脑一想,高度贴图不靠谱。首先在片元着色器里反复采样图片 4 次外加一次叉乘运算就开销不小,我们可能需要一个更牛逼的方案来简单帮我们搞定。
那就是法线贴图,非常有名。
我们把贴图改成 NormalMap,并且 creat from grayscale
法线贴图的采样是非常简单的:颜色的范围是 0-1,法线每个分量的范围是-1,1.
因此我们对采样出来的颜色*2-1 即可
另外注意的是,我们需要交换 z 和 y 的分量
1i.normal = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv).xyz*2.0 - 1.0;
2i.normal = i.normal.xzy;
3i.normal = normalize(i.normal);
额,看起来似乎不太对。。。
这里直接贴上原文的解释吧
这是因为 Unity 最终以与我们预期不同的方式对法线进行编码。尽管纹理预览显示 RGB 编码,但 Unity 实际上使用的是 DXT5nm。
DXT5nm 格式仅存储法线的 X 和 Y 分量。其 Z 分量将被丢弃。正如您所料,Y 分量存储在 G 通道中。但是,X 分量存储在 A 通道中。不使用 R 和 B 通道。
为什么以这种方式存储 X 和 Y?
使用四通道纹理来仅存储两个通道似乎很浪费。当使用未压缩的纹理时,确实如此。DXT5nm 格式的思路是它应该与 DXT5 纹理压缩一起使用。默认情况下,Unity 会执行此作。
DXT5 通过将 4×4 像素的块分组并使用两种颜色和查找表来压缩像素。用于颜色的位数因通道而异。R 和 B 各获得 5 位,G 获得 6 位,A 获得 8 位。这就是 X 坐标移动到 A 通道的原因之一。另一个原因是 RGB 通道获得一个 Looking Table,而 A 获得自己的查找表。这将使 X 和 Y 分量保持隔离。
压缩是有损的,但对于法线贴图来说是可以接受的。与未压缩的 8 位 RGB 纹理相比,您可以获得 3:1 的压缩比。
Unit 使用 DXT5nm 格式对所有法线贴图进行编码,无论您是否实际压缩它们。但是,当以移动平台为目标时,情况并非如此,因为它们不支持 DXT5。在这些情况下,Unity 将使用常规 RGB 编码。
因此,当使用 DXT5nm 时,我们只能检索法线的前两个分量。
1i.normal.xy = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv).wy*2.0 - 1.0;
然后我们利用法线长度为 1 来计算另一个分量,最后再交换 y 和 z 的位置
1i.normal.z = sqrt(1-dot(i.normal.xy,i.normal.xy));
2i.normal = i.normal.xzy;
然后我们会法线很多小黑点:
这是由于精度的限制,导致了在求 i.normal 的长度的时候有可能超过了 1,那么最终的值就会是一个错误的值,因此我们要对之做些限制,使得这个值限制在 0-1 之间
1i.normal.xy = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv).wy*2.0 - 1.0;
2i.normal.z = sqrt(1-saturate(dot(i.normal.xy,i.normal.xy)));
3i.normal = i.normal.xzy;
现在好多了
我们有官方提供给我们的方法
1i.normal.xyz = UnpackNormalAG(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv));
2i.normal = i.normal.xzy;
3i.normal = normalize(i.normal);
我们需要另一个参数来调节法线贴图的强度
1_BumpScale ("Bump Scale", Range(0,1)) = 1
然后使用,对应的带有 scale 的方法
1i.normal.xyz = UnpackNormalmapRGorAG(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv), _BumpScale);
然后我们可以自由调节 scale 了
我们将 albedo 颜色给上,现在是这样的
我们尝试为之添加一下 detailmap
关于 detailMap,我们需要第二套 uv,我们可以简单把第二套 uv 的信息存储到原来 uv 的 zw 分量上来
1_DetailTex ("Detail Texture", 2D) = "gray" {}
1struct v2f
2{
3 float4 uv : TEXCOORD0;
4 float4 vertex : SV_POSITION;
5 float3 normal : NORMAL;
6 float3 worldPos : TEXCOORD1;
7};
1o.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
2o.uv.zw = TRANSFORM_TEX(v.uv, _DetailTex);
然后最后在所有的采样部分,要注意把原来的 i.uv 变成 i.uv.xy,第二套 uv 改成 i.uv.zw
1float3 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv.xy).rgb;
2
3albedo*=SAMPLE_TEXTURE2D(_DetailTex, sampler_DetailTex, i.uv.zw).rgb;
得到如下结果,上图为有细节贴图的,下图为没有的
好吧,几乎看不出区别
同样,我们可以给出第二套细节的 normalMap,我们还是从 grayscale 创建一张 normalMap
代码我略过了声明,只需要生命一个贴图和一个 scale 的参数即可
1float3 detailNormal = UnpackNormalmapRGorAG(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv.xy), _DetailBumpScale);
2i.normal = detailNormal.xzy;
3i.normal = normalize(i.normal);
可以看到一些非常细节的凸起
那么,接下来的问题是,我们如何将两套法线融合到一起呢?
我们首先想到的方式是:平均。但是这是一个想想就非常愚蠢的做法:法线平均毫无意义,而且会把原来的法线展平。
我们尝试从数学原理上来推导
我们首先来思考我们的高度贴图是怎么映射成法线的
1float fx_delta = SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x+_HeightTex_TexelSize.x*0.5,i.uv.y)).rgb
2- SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x-_HeightTex_TexelSize.x*0.5,i.uv.y)).rgb;
3float3 tangle_x = float3(1,fx_delta,0);
4
5float fz_delta = SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x,i.uv.y+_HeightTex_TexelSize.y*0.5)).rgb
6- SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x,i.uv.y-_HeightTex_TexelSize.y*0.5)).rgb;
7float3 tangle_z = float3(0,fz_delta,1);
8
9i.normal = cross(tangle_z,tangle_x);
10i.normal = normalize(i.normal);
我们将最后的叉乘展开,得到的是
1i.normal = float3(fx_delta,1,fz_delta)
其中 fx_delta 代表的是在(x,y)这个点 f(x+delta*0.5,y)-f(x-delta*0.5,y)的值。Delta 我们在这里取了_HeightTex_TexelSize.x。fz_delta 同理。
那么我们应用细节法线的时候,我们在做什么?本质上是针对我们原来法线里的这些高度变化(fx_delta,fz_delta)再做一次添加。因此假设我们的 normal1 是原法线贴图的法线,normal2 是我们细节法线贴图的法线,我们应该得到的高度应该是 normal3 这样:x 分量和 z 分量分别相加,y 分量保持不变
1float3 normal1 = float3(fx_delta1,1,fz_delta1)
2float3 normal2 = float3(fx_delta2,1,fz_delta2)
3float3 normal3 = float3(fx_delta1+fx_delta2,1,fz_delta1+fz_delta2)
当然,对于采样出来的结果。我们没有办法保证 y 分量为 1,所以我们需要先缩放向量,来使得其 y 值为 1
因此我们可以得到
1i.normal.xyz = UnpackNormalmapRGorAG(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv.xy), _BumpScale);
2i.normal = i.normal.xzy;
3
4float3 detailNormal = UnpackNormalmapRGorAG(SAMPLE_TEXTURE2D(_DetailNormalMap, sampler_DetailNormalMap, i.uv.xy), _DetailBumpScale);
5detailNormal = detailNormal.xzy;
6i.normal.xz += detailNormal.xz;
7i.normal = normalize(i.normal);
得到的结果:上图为 detailbumpness=0,下图为 detailbump ness=1
可以看到后面圈出来的凸起的细节
我们尝试在法线映射出来的 xyz 转成 xzy 之前做这个融合,少些两行代码,也就是原文给出的计算方法(你会发现和我推导出的结果是一致的)
1 float3 mainNormal = UnpackNormalmapRGorAG(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv.xy), _BumpScale);
2
3 float3 detailNormal = UnpackNormalmapRGorAG(SAMPLE_TEXTURE2D(_DetailNormalMap, sampler_DetailNormalMap, i.uv.xy), _DetailBumpScale);
4 i.normal = float3(mainNormal.xy / mainNormal.z + detailNormal.xy / detailNormal.z, 1);
5 i.normal = i.normal.xzy;
6i.normal = normalize(i.normal);
继续优化,这里直接用原文的一些内容来解释好了
在组合大部分是平面的地图时,它的效果非常好。但是,合并陡峭的斜坡仍会丢失细节。一种稍微替代的方法是 whiteout 混合。首先,将新常态乘以 MzDzMzDz.我们可以这样做,因为无论如何我们之后都会正常化。这给了我们向量
然后去掉 X 和 Y 的缩放,导致
此调整会夸大 X 和 Y 分量,从而在陡峭的斜坡上产生更明显的凹凸。但是,当其中一条法线平坦时,另一条法线不会改变。
1i.normal =float3(mainNormal.xy + detailNormal.xy, mainNormal.z * detailNormal.z);
最后,我们还是用回 unity 里给出的方法
1i.normal = BlendNormal(mainNormal,detailNormal);
其实会发现里面的计算是一样的
现在我们的法线贴图看起来很完美,其实是不太对的,比如从这个球体就能看出来
因为我们原来的假设是:uv 方向分别是世界空间的 xz 方向,我们才能得到一些看似正确的结果。那么当加了变换或者是换成球面这种不规则的坐标系以后,我们就没有办法继续用之前的方案来处理了。
我们需要在每一个点上定义我们前面处理的那种三维空间。首先我们知道的是法向量,那么除此以外我们还需要分别代表 u,v 的两个向量。
事实上我们只需要知道一个即可,另一个可以通过和法向量的叉乘得出。我们暂时称这个向量为附加向量。
对于这个附加向量,我们的网格数据其实给出了一个好的方向,那就是切线向量 T。按照惯例我们将之与 u 轴匹配,指向右侧。
第三个向量 B(bnormal)=N x T.但是这会生成一个反向的向量。我们需要将结果乘以-1.
关于为什么,原文的解释是:
1为什么在切线向量中存储 −1 ?
2
3在创建具有双边对称性的 3D 模型(如人和动物)时,一种常见的技术是左右镜像网格。这意味着您只需编辑网格的一侧。而且您只需要原本需要的一半纹理数据。这意味着法线向量和切线向量也会被镜像。但是,不应镜像 binormal!为了支持这一点,镜像切线在其第四个分量中存储 1,而不是 −1。所以这个数据实际上是可变的。这就是为什么必须明确提供它的原因。
这样说还是太过抽象,我们尝试用 C#的绘制方法来看一看具体切线空间长什么样子。
用一个 DrawGizmo 把所有的方向都画出来:N,T,B
1public class TangentSpaceVisualizer : MonoBehaviour
2{
3 public float offset = 0.01f;
4 public float scale = 0.1f;
5
6 private void OnDrawGizmos()
7 {
8 MeshFilter filter = GetComponent<MeshFilter>();
9 if (filter) {
10 Mesh mesh = filter.sharedMesh;
11 if (mesh) {
12 ShowTangentSpace(mesh);
13 }
14 }
15 }
16
17
18 void ShowTangentSpace (Mesh mesh) {
19 Vector3[] vertices = mesh.vertices;
20 Vector3[] normals = mesh.normals;
21 Vector4[] tangents = mesh.tangents;
22 for (int i = 0; i < vertices.Length; i++) {
23 ShowTangentSpace(
24 transform.TransformPoint(vertices[i]),
25 transform.TransformDirection(normals[i]),
26 transform.TransformDirection(tangents[i])
27 );
28 }
29 }
30
31 void ShowTangentSpace (Vector3 vertex, Vector3 normal, Vector3 tangent) {
32 vertex += normal * offset;
33 Gizmos.color = Color.green;
34 Gizmos.DrawLine(vertex, vertex + normal * scale);
35 Gizmos.color = Color.red;
36 Gizmos.DrawLine(vertex, vertex + tangent * scale);
37
38 Gizmos.color = Color.cyan;
39 Gizmos.DrawLine(vertex, vertex + Vector3.Cross(normal,tangent) * scale);
40
41 }
42}
明显能看出来我们的叉乘方向是反了的。正常的 UV 坐标系应该是这样的:
补充一点基础知识:如何判断叉乘方向?这要取决于我们的坐标系是左手坐标系还是右手坐标系
大拇指指向 +x,食指指向 +y,中指指向 +z.然后看哪只手符合就用哪只手。
那么对应判断叉乘方向的,就用对应的左右手法则即可。左手坐标系用左手,右手坐标系用右手。
Unity 是左手坐标系
所以左手法则一下,自然也知道正确方向了
因此我们需要用到前面所说的,切线分量中的 w 来取相反方向
代码变成
1void ShowTangentSpace (Mesh mesh) {
2 Vector3[] vertices = mesh.vertices;
3 Vector3[] normals = mesh.normals;
4 Vector4[] tangents = mesh.tangents;
5 for (int i = 0; i < vertices.Length; i++) {
6 ShowTangentSpace(
7 transform.TransformPoint(vertices[i]),
8 transform.TransformDirection(normals[i]),
9 transform.TransformDirection(tangents[i]),
10 tangents[i].w
11 );
12 }
13}
14
15void ShowTangentSpace (Vector3 vertex, Vector3 normal, Vector3 tangent, float w) {
16 vertex += normal * offset;
17 Gizmos.color = Color.green;
18 Gizmos.DrawLine(vertex, vertex + normal * scale);
19 Gizmos.color = Color.red;
20 Gizmos.DrawLine(vertex, vertex + tangent * scale);
21
22 Gizmos.color = Color.cyan;
23 Gizmos.DrawLine(vertex, vertex + Vector3.Cross(normal,tangent) * scale);
24
25}
现在看起来正确多了
接下来我们要做的事情就非常简单了
首先从 appdata 中引入切线:
1struct appdata
2{
3 float4 vertex : POSITION;
4 float2 uv : TEXCOORD0;
5 float3 normal : NORMAL;
6 float4 tangent : TANGENT;
7};
v2f 也安排上
1struct v2f
2{
3 float4 uv : TEXCOORD0;
4 float4 vertex : SV_POSITION;
5 float3 normal : TEXCOORD1;
6 float3 worldPos : TEXCOORD2;
7 float4 tangent : TEXCOORD3;
8};
要得到需要用一下转换
1v2f vert (appdata v)
2{
3 v2f o;
4 o.vertex = TransformObjectToHClip(v.vertex);
5 o.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
6 o.uv.zw = TRANSFORM_TEX(v.uv, _DetailTex);
7
8 o.normal = mul(transpose((float3x3)unity_WorldToObject), float4(v.normal,0));
9
10 o.worldPos = TransformObjectToWorld(v.vertex.xyz);
11
12 o.tangent = float4(TransformObjectToWorldDir(v.tangent.xyz), v.tangent.w);
13
14 return o;
15}
关于这个 TransformObjectToWorldDir
其实就是一个针对方向向量变成 3x3 矩阵而已,别的没有什么不同
关于片元:
1half4 frag (v2f i) : SV_Target
2{
3 i.normal = normalize(i.normal);
4
5 // sample the texture
6 float3 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv.xy).rgb;
7
8 albedo*=SAMPLE_TEXTURE2D(_DetailTex, sampler_DetailTex, i.uv.zw).rgb;
9
10 float3 mainNormal = UnpackNormalmapRGorAG(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv.xy), _BumpScale);
11
12 float3 detailNormal = UnpackNormalmapRGorAG(SAMPLE_TEXTURE2D(_DetailNormalMap, sampler_DetailNormalMap, i.uv.xy), _DetailBumpScale);
13 float3 tangentSpaceNormal = BlendNormal(mainNormal, detailNormal);
14 tangentSpaceNormal = tangentSpaceNormal.xzy;
首先将我们之前算出来的法线变成一个单独的变量
计算切线空间的各个轴(其实只是计算一下 binormal 轴)
1float3 binomal = cross(i.normal, i.tangent.xyz)* i.tangent.w; ;
然后将之作为基底,相乘求和。最后再归一化
1i.normal = (i.tangent* tangentSpaceNormal.x+i.normal* tangentSpaceNormal.y+ binomal* tangentSpaceNormal.z);
2i.normal = normalize(i.normal);
因为我们在最后做了一次归一化的,因此我们其实不需要专门把基向量(Normal,tangent,binormal)归一化。
我们来对比一下相同配置下的不同颜色:
错误的版本:
正确的版本:
大功告成!
加上一些光照,调节一下参数,得到了一个水嫩之中带着点恶心的球
直接用原文,解释得很清楚了
在构造 binormal 时,还有一个额外的细节。假设对象的比例设置为 (-1, 1, 1)。这意味着它是镜像的。在这种情况下,我们必须翻转 binormal,以正确镜像切线空间。事实上,当奇数个维度为负数时,我们必须这样做。通过定义**
float4
**unity_WorldTransformParams
变量来帮助我们解决这个问题。当我们需要翻转二态时,它的第四个分量包含 −1,否则包含 1。
标准很重要。如果为引擎生产的贴图与最终引擎的法线算法不同,那确实非常恐怖。
一般来说,模型是不会导出切线的,需要在导入游戏引擎的时候通过某种算法自行计算模型切线,这时候就需要一种算法。
比较有名的是 mikktspace 算法。导入 Unity 网格时,可以选择使用 mikktspace**来生成切线。**这里就不展开讨论这个切线算法了,似乎是开源的,有兴趣的可以参考一下。
既然我们在 fragment 里计算了 binormal,自然我们也能够在顶点着色器里计算 binormal 从而推导出切线空间。
我们需要的东西除了必要的代码以外,我们还需要一个针对 binormal 的插值器。这个插值器显然与其他重心坐标插值的方案不同。
这里就不展开了,我没太理解原文里是怎么通过一个宏来控制 vertex 到 fragment 的插值器的。盲猜是定义一个宏以后单纯提前预处理了一些代码,但是我没有证据
原本以为这一部分的内容是比较浅而且很熟悉的,原打算是跳过的,但是真正看下来却觉得自己很多细节都不甚了解,包括高度图怎么到法线贴图,包括法线的混合。
虽然感觉过两天就忘光了,但是自己真的全部手敲调试一遍,然后真的哪天需要参考对照的时候,再回来复习,也不枉花这么多精力一点点写了。
纸上得来终觉浅,绝知此事要躬行。