本教程涵盖了(单步)视差贴图。
它基于章节“凹凸曲面光照”并进行了扩展。
改进法线贴图
在章节“凹凸曲面的光照”中展示的法线贴图技术只是改变了平面的光照以便产生凹凸的错觉。如果直视一个曲面(也就是曲面法向量的方向),这技术有很好的表现。但是,如果从其它角度看向曲面(如上图所示),凸起应该伸出曲面而凹陷应该低于曲面。当然,这可以通过几何模拟凸起和凹陷来实现;但是,这需要处理更多的顶点数据。另一方面,类似法线贴图单步视差贴图是一种非常有效的技术,它不要求额外的三角形,但仍然能够移动一些像素的虚拟凸起点以便让它们伸出平面。但是,这种技术受限于高度较低的凹凸点并且为了更好的效果需要一些微调。
视差贴图是2001年由Tomomichi Kaneko等人在论文“视差贴图的详细形状表述”中提出的。它基础思想是偏移曲面纹理使用的纹理坐标(特别是法线贴图)。如果纹理坐标的偏移计算恰当,就有可能移动部分纹理(比如凸起)像是它们伸出了表面。
视差贴图释疑
上图的解释展示了指向观察者的视向量V和在表面上片元着色器光栅化的点的法向量N。视差贴图分3步处理:
- 在高度图中查询光栅化点的高度h,它由直线上面的波浪线和插画底部来描述。
- 在平行于渲染表面的高度h的平面与V方向上的观察射线的交叉点的计算。距离o是指在N方向移动h的光栅化的表面点和上面交叉点之间的距离。如果这两个点被投影到渲染表面上,o同样是光栅化点和表面上新的点(插图上用X标记)之间的距离。如果表面被高度图替换的话,这个新的表面点是对Y方向上的观察射线实际可见点的较好模拟。
- 偏移距离o向纹理坐标空间的变换是为了计算出以下所有纹理查找的纹理坐标的偏移。
对于o的计算,我们需要位于光栅化点高度力的高度h,它是通过纹理属性_ParallaxMap的A分量中的纹理查找来实现的,而纹理应该是一张代表着章节“凹凸曲面光照”中讨论的高度的灰度图。我们也需要由法向量z、切线向量x以及副法线向量形成的本地曲面坐标系中的观察方向V,它在章节“凹凸曲面光照”中也有介绍。最后我们计算从本地曲面坐标向对象空间的变换:
T、B和N是在对象坐标中的。(在章节“凹凸曲面光照”中我们有一个类似的矩阵但是向量是在世界坐标系中的。)
我们在对象空间中计算观察方向V(即光栅化位置和从世界空间转换到对象空间的摄像机位置之间的差),然后我们用下面的矩阵把它变换到本地曲面空间:
很有可能是因为T、B和N是互相垂直和归一化的。(实际上,情况稍微有点复杂因为我们我们不需要归一化这些向量,但要为另一个变换使用它们的长度;接着看下去)于是,为了把V从对象空间变换到本地曲面空间,我们必须把它乘以转置矩阵。这其实很不错,因为在Cg中很简单就能构造转置矩阵,T、B和N是转置矩阵的行向量。
一旦我们在z轴在法向量N的方向上的本地曲面坐标系统中获得V,我们通过使用相似三角形能够计算偏移(在x方向)和(在y方向):
于是:
注意,归一化V并不是很有必要,因为我们只是用它分量的比率,而这个不受归一化的影响。
最终,我们要把和变换到纹理空间中。如果Unity不帮助我们的话会很困难:切线属性tangent
实际上会适当地缩放并且第四个分量tangent.w
会缩放副法线向量,这样观察方向V的变换会适当地缩放和以便和在纹理坐标空间中不需要进一步计算。
实现
实现方法共享了很多章节“凹凸曲面的光照”中的代码。特别是,用切线属性的第四个分量对副法线向量作相同缩放,这个被用来考虑偏移从本地曲面空间到纹理空间的映射:
float3 binormal = cross(input.normal, input.tangent.xyz) * input.tangent.w;
我们必须在本地曲面坐标系中为观察向量V添加一个输出参数(通过坐标轴的缩放要把映射到纹理空间考虑进去)。这个参数叫做viewDirInScaledSurfaceCoords
,它通过用上面解释的矩阵(localSurface2ScaledObjectT
)变换对象坐标(viewDirInObjectCoords
)中的观察向量来计算。
float3 viewDirInObjectCoords = mul(
modelMatrixInverse, float4(_WorldSpaceCameraPos, 1.0)).xyz
- input.vertex.xyz;
float3x3 localSurface2ScaledObjectT =
float3x3(input.tangent.xyz, binormal, input.normal);
// vectors are orthogonal
output.viewDirInScaledSurfaceCoords =
mul(localSurface2ScaledObjectT, viewDirInObjectCoords);
// we multiply with the transpose to multiply with
// the "inverse" (apart from the scaling)
顶点着色器剩下的部分跟法线贴图一样,参考章节“凹凸曲面的光照”,除了世界坐标中的观察方向是在顶点着色器而不是片元着色器中计算的,对于某些GPU来说保证片元着色器中足够少的数学运算的数目是很有必要的。
在片元着色器中,我们首先为光栅化点的高度查询高度图。高度由纹理_ParallaxMap
的A分量指定。0到1之间的值会通过着色器属性_Parallax
变换到-_Parallax/2和+_Parallax之间,为了就是向用户提供控制效果的强度(并且要跟备用着色器兼容):
float height = _Parallax
* (-0.5 + tex2D(_ParallaxMap, _ParallaxMap_ST.xy
* input.tex.xy + _ParallaxMap_ST.zw).x);
偏移量和会如上面描述的一样被计算。但是,我们也会把每个偏移限制在用户自定义的区间_MaxTexCoordOffset
和_MaxTexCoordOffset
,为了保证偏移是在合理的范围内。(如果高度图包含或多或少在这些高原平滑过度的恒定高度的平原(完全不知道怎么翻译了,救命啊),_MaxTexCoordOffset
应小于这些过渡区域的厚度;否则采样点会在不同高度的不同高原上,也就意味着交叉点的近似值是任意差的。)代码如下:
float2 texCoordOffsets =
clamp(height * input.viewDirInScaledSurfaceCoords.xy
/ input.viewDirInScaledSurfaceCoords.z,
-_MaxTexCoordOffset, +_MaxTexCoordOffset);
在以下代码中,我们必须在所有的纹理查询中应用这个偏移;也就是我们必须用(input.tex.xy + texCoordOffsets)
替换float2(input.tex)
(或等价于input.tex.xy),举例来说:
float4 encodedNormal = tex2D(_BumpMap,
_BumpMap_ST.xy * (input.tex.xy + texCoordOffsets)
+ _BumpMap_ST.zw);
这个片元着色器剩下的代码就跟章节“凹凸曲面光照”中一样了。
完整的着色器代码
就跟在之前章节中讨论的一样,大部分代码来自于章节“凹凸曲面光照”。注意如果你要在有OpenGL ES的移动设备上使用该代码,请确保如那教程描述的改变法线贴图的解码。
关于视差贴图的部分只有少数代码。大多数着色器属性名字是根据备用着色器来选择的;用户界面标签就更具有描述性。
Shader "Cg parallax mapping" {
Properties {
_BumpMap ("Normal Map", 2D) = "bump" {}
_ParallaxMap ("Heightmap (in A)", 2D) = "black" {}
_Parallax ("Max Height", Float) = 0.01
_MaxTexCoordOffset ("Max Texture Coordinate Offset", Float) =
0.01
_Color ("Diffuse Material Color", Color) = (1,1,1,1)
_SpecColor ("Specular Material Color", Color) = (1,1,1,1)
_Shininess ("Shininess", Float) = 10
}
CGINCLUDE // common code for all passes of all subshaders
#include "UnityCG.cginc"
uniform float4 _LightColor0;
// color of light source (from "Lighting.cginc")
// User-specified properties
uniform sampler2D _BumpMap;
uniform float4 _BumpMap_ST;
uniform sampler2D _ParallaxMap;
uniform float4 _ParallaxMap_ST;
uniform float _Parallax;
uniform float _MaxTexCoordOffset;
uniform float4 _Color;
uniform float4 _SpecColor;
uniform float _Shininess;
struct vertexInput {
float4 vertex : POSITION;
float4 texcoord : TEXCOORD0;
float3 normal : NORMAL;
float4 tangent : TANGENT;
};
struct vertexOutput {
float4 pos : SV_POSITION;
float4 posWorld : TEXCOORD0;//世界空间中顶点(或片元)的位置
float4 tex : TEXCOORD1;
float3 tangentWorld : TEXCOORD2;
float3 normalWorld : TEXCOORD3;
float3 binormalWorld : TEXCOORD4;
float3 viewDirWorld : TEXCOORD5;
float3 viewDirInScaledSurfaceCoords : TEXCOORD6;
};
vertexOutput vert(vertexInput input)
{
vertexOutput output;
float4x4 modelMatrix = _Object2World;
float4x4 modelMatrixInverse = _World2Object;
output.tangentWorld = normalize(
mul(modelMatrix, float4(input.tangent.xyz, 0.0)).xyz);
output.normalWorld = normalize(
mul(float4(input.normal, 0.0), modelMatrixInverse).xyz);
output.binormalWorld = normalize(
cross(output.normalWorld, output.tangentWorld)
* input.tangent.w); // tangent.w is specific to Unity
float3 binormal = cross(input.normal, input.tangent.xyz)
* input.tangent.w;
// appropriately scaled tangent and binormal
// to map distances from object space to texture space
float3 viewDirInObjectCoords = mul(
modelMatrixInverse, float4(_WorldSpaceCameraPos, 1.0)).xyz
- input.vertex.xyz;
float3x3 localSurface2ScaledObjectT =
float3x3(input.tangent.xyz, binormal, input.normal);
// vectors are orthogonal
output.viewDirInScaledSurfaceCoords =
mul(localSurface2ScaledObjectT, viewDirInObjectCoords);
// we multiply with the transpose to multiply with
// the "inverse" (apart from the scaling)
output.posWorld = mul(modelMatrix, input.vertex);
output.viewDirWorld = normalize(
_WorldSpaceCameraPos - output.posWorld.xyz);
output.tex = input.texcoord;
output.pos = mul(UNITY_MATRIX_MVP, input.vertex);
return output;
}
// fragment shader with ambient lighting
float4 fragWithAmbient(vertexOutput input) : COLOR
{
// parallax mapping: compute height and
// find offset in texture coordinates
// for the intersection of the view ray
// with the surface at this height
float height = _Parallax
* (-0.5 + tex2D(_ParallaxMap, _ParallaxMap_ST.xy
* input.tex.xy + _ParallaxMap_ST.zw).x);
float2 texCoordOffsets =
clamp(height * input.viewDirInScaledSurfaceCoords.xy
/ input.viewDirInScaledSurfaceCoords.z,
-_MaxTexCoordOffset, +_MaxTexCoordOffset);
// normal mapping: lookup and decode normal from bump map
// in principle we have to normalize tangentWorld,
// binormalWorld, and normalWorld again; however, the
// potential problems are small since we use this
// matrix only to compute "normalDirection",
// which we normalize anyways
float4 encodedNormal = tex2D(_BumpMap,
_BumpMap_ST.xy * (input.tex.xy + texCoordOffsets)
+ _BumpMap_ST.zw);
float3 localCoords = float3(2.0 * encodedNormal.a - 1.0,
2.0 * encodedNormal.g - 1.0, 0.0);
localCoords.z = sqrt(1.0 - dot(localCoords, localCoords));
// approximation without sqrt: localCoords.z =
// 1.0 - 0.5 * dot(localCoords, localCoords);
float3x3 local2WorldTranspose = float3x3(
input.tangentWorld,
input.binormalWorld,
input.normalWorld);
float3 normalDirection =
normalize(mul(localCoords, local2WorldTranspose));
float3 lightDirection;
float attenuation;
if (0.0 == _WorldSpaceLightPos0.w) // directional light?
{
attenuation = 1.0; // no attenuation
lightDirection = normalize(_WorldSpaceLightPos0.xyz);
}
else // point or spot light
{
float3 vertexToLightSource =
_WorldSpaceLightPos0.xyz - input.posWorld.xyz;
float distance = length(vertexToLightSource);
attenuation = 1.0 / distance; // linear attenuation
lightDirection = normalize(vertexToLightSource);
}
float3 ambientLighting =
UNITY_LIGHTMODEL_AMBIENT.rgb * _Color.rgb;
float3 diffuseReflection =
attenuation * _LightColor0.rgb * _Color.rgb
* max(0.0, dot(normalDirection, lightDirection));
float3 specularReflection;
if (dot(normalDirection, lightDirection) < 0.0)
// light source on the wrong side?
{
specularReflection = float3(0.0, 0.0, 0.0);
// no specular reflection
}
else // light source on the right side
{
specularReflection = attenuation * _LightColor0.rgb
* _SpecColor.rgb * pow(max(0.0, dot(
reflect(-lightDirection, normalDirection),
input.viewDirWorld)), _Shininess);
}
return float4(ambientLighting + diffuseReflection
+ specularReflection, 1.0);
}
// fragement shader for pass 2 without ambient lighting
float4 fragWithoutAmbient(vertexOutput input) : COLOR
{
// parallax mapping: compute height and
// find offset in texture coordinates
// for the intersection of the view ray
// with the surface at this height
float height = _Parallax
* (-0.5 + tex2D(_ParallaxMap, _ParallaxMap_ST.xy
* input.tex.xy + _ParallaxMap_ST.zw).x);
float2 texCoordOffsets =
clamp(height * input.viewDirInScaledSurfaceCoords.xy
/ input.viewDirInScaledSurfaceCoords.z,
-_MaxTexCoordOffset, +_MaxTexCoordOffset);
// normal mapping: lookup and decode normal from bump map
// in principle we have to normalize tangentWorld,
// binormalWorld, and normalWorld again; however, the
// potential problems are small since we use this
// matrix only to compute "normalDirection",
// which we normalize anyways
float4 encodedNormal = tex2D(_BumpMap,
_BumpMap_ST.xy * (input.tex.xy + texCoordOffsets)
+ _BumpMap_ST.zw);
float3 localCoords = float3(2.0 * encodedNormal.a - 1.0,
2.0 * encodedNormal.g - 1.0, 0.0);
localCoords.z = sqrt(1.0 - dot(localCoords, localCoords));
// approximation without sqrt: localCoords.z =
// 1.0 - 0.5 * dot(localCoords, localCoords);
float3x3 local2WorldTranspose = float3x3(
input.tangentWorld,
input.binormalWorld,
input.normalWorld);
float3 normalDirection =
normalize(mul(localCoords, local2WorldTranspose));
float3 lightDirection;
float attenuation;
if (0.0 == _WorldSpaceLightPos0.w) // directional light?
{
attenuation = 1.0; // no attenuation
lightDirection = normalize(_WorldSpaceLightPos0.xyz);
}
else // point or spot light
{
float3 vertexToLightSource =
_WorldSpaceLightPos0.xyz - input.posWorld.xyz;
float distance = length(vertexToLightSource);
attenuation = 1.0 / distance; // linear attenuation
lightDirection = normalize(vertexToLightSource);
}
float3 diffuseReflection =
attenuation * _LightColor0.rgb * _Color.rgb
* max(0.0, dot(normalDirection, lightDirection));
float3 specularReflection;
if (dot(normalDirection, lightDirection) < 0.0)
// light source on the wrong side?
{
specularReflection = float3(0.0, 0.0, 0.0);
// no specular reflection
}
else // light source on the right side
{
specularReflection = attenuation * _LightColor0.rgb
* _SpecColor.rgb * pow(max(0.0, dot(
reflect(-lightDirection, normalDirection),
input.viewDirWorld)), _Shininess);
}
return float4(diffuseReflection + specularReflection,
1.0);
}
ENDCG
SubShader {
Pass {
Tags { "LightMode" = "ForwardBase" }
// pass for ambient light and first light source
CGPROGRAM
#pragma vertex vert
#pragma fragment fragWithAmbient
// the functions are defined in the CGINCLUDE part
ENDCG
}
Pass {
Tags { "LightMode" = "ForwardAdd" }
// pass for additional light sources
Blend One One // additive blending
CGPROGRAM
#pragma vertex vert
#pragma fragment fragWithoutAmbient
// the functions are defined in the CGINCLUDE part
ENDCG
}
}
}
总结
恭喜!如果你要实际上理解整个着色器,还有很长的路要走。实际上,这个着色器包括了很多概念(坐标系之间的变换、Phone反射模型、法线贴图、视差贴图等等)。更具体地说,我们学到了:
- 视差贴图如何在法线贴图上进行提升。
- 如何在数学上描述视差贴图。
- 如何实现视差贴图。
译者注:
1.Bump Mapping(凹凸贴图)通过改变几何体表面各点的法线,使本来是平的东西看起来有凹凸的效果,是一种欺骗眼睛的技术。Bump Mapping通过一张Height Map(高度图)记录各象素点的高度信息。
2.Normal Mapping(法线贴图)也叫做Dot3 Bump Mapping,它也是Bump Mapping的一种,区别在于Normal Mapping技术直接把Normal存到一张NormalMap里面,从NormalMap里面采回来的值就是Normal,不需要像HeightMap那样再经过额外的计算。
3. Parallax Mapping(视差贴图),当使用Normal Mapping技术时,并没有把视线方向考虑进去。在真实世界中,如果物体表面高低不平,当视线方向不同时,看到的效果也不相同。Parallax Mapping就是为了解决此问题而提出的。
4. 参考
种类 | 高度图 | 法线图 | 随视点变化 | 自阴影 | 性能 |
---|---|---|---|---|---|
Bump Mapping | 需要 | 不需要 | 否 | 无 | 快 |
Normal Mapping | 不需要 | 需要 | 否 | 无 | 快 |
Parallax Mapping | 不需要 | 需要 | 是 | 无 | 较快 |