完整的工程会上传到个人代码仓库(链接),与书籍代码类似,但是包含了大量的个人中文注释(不是照搬书上的解释)和一些理解,看起来会比书上更友好。
纹理最初的目的就是使用一张图片来控制模型的外观,使用纹理映射技术,我们可以把一张图“黏”在模型表面,逐纹素地控制模型颜色。
目录
普通纹理
凹凸映射
法线纹理
法线方向与像素存储的映射
法线纹理所处的坐标空间
切线空间法线纹理的优势
那在切线空间还是世界空间计算效果?
法线纹理实现代码与效果
渐变纹理
遮罩纹理
高级纹理
立方体纹理
立方体纹理-反射
立方体纹理-折射
立方体纹理-菲涅耳反射
渲染纹理
镜子效果
玻璃效果
GrabPass的两种存储形式
程序纹理
程序材质
PS:单张纹理没啥好说的,就是获取一张纹理图片,然后逐纹素取对应的颜色,赋值给模型,所以我们直接从法线纹理开始吧。
凹凸映射的目的是使用一张修改模型表面的法线,来为模型提供更多的凹凸细节。这种方法不会真正的改变模型顶点的位置,只是让模型看起来“凹凸不平”的,但是可以从模型的轮廓处看出“破绽”。
有两种主要的方法可以实现该效果,高度映射和法线纹理,但是我们常常只讨论法线纹理,因为法线纹理可以提供更加丰富的效果,并且应用范围更广。
法线纹理中存储的就是表面的法线方向,由于法线方向的分量范围在[-1,1],而像素的分量范围为[0,1],因此我们需要做一个映射,通常使用的映射就是,具体提取使用时,再进行反映射。,
法线纹理存储的法线方向,而方向是相对坐标空间的。法线纹理中的法线方向一般相对于,两个坐标空间。
法线存储在切线空间下,有自由度高,简单的法线扰动就可以实现UV动画,可以重用法线,可以压缩存储等优势。下面我们看一下,在切线空间下存储法线纹理的优势的具体解释。
如果只是有法线参与的简单光照计算,推荐在切线空间下计算效果。因为我们可以在顶点着色器中就将对光照方向和视角方向变换到切线空间,比较节省性能。
但是如果有比较复杂的效果需要处理,建议将法线从切线空间转换到世界空间。因为很多的效果是需要在世界空间完成的(比如cube的环境映射),反向转换到切线空间反而得不偿失。这种方法有个基础计算思路,在顶点着色器中计算切线空间到世界空间的变换矩阵,并把它传递给片元着色器。变换矩阵的计算可以由顶点的切线、副切线和法线在世界空间下表示来得到。最后在片元中把法线纹理中提取的法线方向,使用变换矩阵变换到世界空间下,计算光照效果。因为在片元中进行了矩阵变换,较上一种方法更消耗性能。
Tip:代码注释中有一些思路。完整代码可以看文章头部的链接。
代码-读取法线纹理并在切线空间下计算光照-NormalMapTangentSpace_Texture.shader
//a2v 一般用于应用到顶点坐标系的输入
struct a2v {
float4 vertex : POSITION;//存储顶点的位置信息
float3 normal : NORMAL;//存储顶点的法线信息,法线信息一般用来计算光照的反射方向等
float4 tangent : TANGENT;//存储顶点的切线信息,因为要计算切线空间所以需要该值
float3 texcoord : TEXCOORD0;//TEXCOORD0存储纹理坐标,在顶点着色器中使用TEXCOORD0语义,Unity会将模型的第一组纹理坐标存储到该变量中
};
struct v2f {
float4 pos : SV_POSITION;//裁剪空间中的顶点坐标
float4 uv : TEXCOORD0;//TODO 什么uv? uv可以暂时简单理解为,在贴图中定位像素的坐标系,那为什么是float4呢,这里是取巧,合理利用存储存储空间,zw分量存储了其他数据,以减少申请寄存器内存的次数。
float3 lightDir : TEXCOORD1;//因为需要将环境光方向转换到切线空间,所以lightDir用于存储转换后的光线方向
float3 viewDir : TEXCOORD2;//因为需要将观察方向转换到切线空间,所以viewDir用于存储转换后的光线方向
};
v2f vert (a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);//转换顶点到裁剪空间
//处理贴图设置中的缩放和偏移,先计算缩放再计算偏移 PS:这里好像是因为前面矩阵中讲的,缩放会影响偏移
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;
// // 通过向量的叉乘计算副法线的方向,这里要注意 * w分量,w的取值应该是1或-1,但是w究竟是什么呢?又从何处取值?
// float3 binormal = cross(normalize(v.normal),normalize(v.tangent.xyz)) * v.tangent.w;//binormal也可以叫bitangent只是一个命名习惯
// // 组合三个方向轴,即x切线,y副法线,z法线,组成切线空间的矩阵
// float3x3 rotation = float3x3(v.tangent.xyz,binormal,v.normal);
// 使用Unity的内置指令直接取得切线空间,将会自动声明并实现binormal和rotation变量
TANGENT_SPACE_ROTATION;
//将光线方向和视角方向转换到切线空间,注意只使用了.xyz
o.lightDir = mul(rotation,ObjSpaceLightDir(v.vertex)).xyz;
o.viewDir = mul(rotation,ObjSpaceViewDir(v.vertex)).xyz;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed3 tangentLightDir = normalize(i.lightDir);
fixed3 tangentViewDir = normalize(i.viewDir);
//对贴图进行采样,即在贴图的对应位置获取纹素值 ps:纹理的像素值
fixed4 packedNormal = tex2D(_BumpMap,i.uv.zw);
fixed3 tangentNormal;
// 如果提供的法线贴图没有在Unity中标明是NormalMap,则需要我们自己处理
// tangentNormal.xy = (packedNormal.xy * 2 - 1) * _BumpScale; // _BumpScale 控制凹凸程度
//因为法线贴图存储的法线向量是单位向量,所以知道了xy后,又知道z垂直于xy,且因为在切线空间中z一定为正,则可以计算出z的分量
//这里不好理解的话,其实画个三维坐标验证一下就好了。建议可以记住这种用法,应该会比较常用。
// tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy,tangentNormal.xy)));
//一般情况下还是建议设置为法线贴图,并使用UnpackNormal,因为Unity对贴图会有压缩,可能造成上述运算的结果不正确
tangentNormal = UnpackNormal(packedNormal);
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy,tangentNormal.xy)));
//后面是光照和颜色的处理
fixed3 albedo = tex2D(_MainTex,i.uv).rgb * _Color.rgb;
//TODO 为什么要乘上环境光得颜色?如果不乘的话颜色会发白(环境光设置的颜色)
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
//再回顾一下漫反射的计算公式 光线颜色 * 物体颜色 * dot(法线方向和光线方向)
fixed3 diffuse = _LightColor0.rgb * albedo *max(0,dot(tangentNormal,tangentLightDir));
//再回顾一下Blinm光照模型公式
fixed3 halfDir = normalize(tangentLightDir + tangentViewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0,dot(tangentNormal,halfDir)),_Gloss);
return fixed4(ambient + diffuse + specular,1.0);
}
代码-读取法线纹理然后转换到世界空间再计算效果-NormalMapWorldSpace_Texture.shader
//a2v 一般用于应用到顶点坐标系的输入
struct a2v {
float4 vertex : POSITION;//存储顶点的位置信息
float3 normal : NORMAL;//存储顶点的法线信息,法线信息一般用来计算光照的反射方向等
float4 tangent : TANGENT;//存储顶点的切线信息,因为要计算切线空间所以需要该值
float3 texcoord : TEXCOORD0;//TEXCOORD0存储纹理坐标,在顶点着色器中使用TEXCOORD0语义,Unity会将模型的第一组纹理坐标存储到该变量中
};
struct v2f {
float4 pos : SV_POSITION;//裁剪空间中的顶点坐标
//TODO 什么uv? uv可以暂时简单理解为,在贴图中定位像素的坐标系,那为什么是float4呢
//后续解释,float4是为了可以节省一个插值寄存器的内存申请
//uv一般应该有两种含义,一种指在贴图中定位像素的坐标系,有横向坐标和纵坐标
//一种是指贴图设置中对应的缩放Tiling和偏移offset两种属性设置,在变量中分别对应,xy和zw分量
float4 uv : TEXCOORD0;
float4 TtoW0 : TEXCOORD1;//因为插值寄存器只能存储float4的变量,所以我们将矩阵转换为三个float4的变量
float4 TtoW1 : TEXCOORD2;
float4 TtoW2 : TEXCOORD3;
};
v2f vert (a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);//转换顶点到裁剪空间
//处理贴图设置中的缩放和偏移,先计算缩放再计算偏移 PS:这里好像是因为前面矩阵中讲的,缩放会影响偏移
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
//TODO 虽然书上说明了,一般情况下,一个纹理和它对应的贴图的uv坐标应该是同一组。但是并没有解释,为什么zw要使用_BumpMap_ST的计算
//后续解释,是因为顶点到片元的插值寄存器一般最多能存放float4大小的变量,也刚好能存放float4的变量,我们申请一个float4的变量,可以节省一个插值寄存器的内存申请。
o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;
//计算切线空间到世界空间的变换矩阵,即切线空间的三个坐标轴在世界空间下的表示
//顶点在世界空间下的坐标
float3 worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
//世界空间下的法线
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
//世界空间下的切线
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);//这里要注意,法线是需要特殊转换的,而切线不需要,参考4.7
//世界空间下的副法线
//TODO 这里有个疑问,乘于w分量是为了确定副法线的方向,但是w是什么取值呢?用世界空间的方向的叉乘在乘于模型空间下的w分量,不会有影响么?
fixed3 worldBinormal = cross(worldNormal,worldTangent) * v.tangent.w;
//组装转换矩阵,注意是xyz分量放在一列,参考4.6.2
//最后一列防止原点,是为了节省空间,这样刚好节省一个寄存器
o.TtoW0 = float4(worldTangent.x,worldBinormal.x,worldNormal.x,worldPos.x);
o.TtoW1 = float4(worldTangent.y,worldBinormal.y,worldNormal.y,worldPos.y);
o.TtoW2 = float4(worldTangent.z,worldBinormal.z,worldNormal.z,worldPos.z);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
//取出世界坐标
float3 worldPos = float3(i.TtoW0.w,i.TtoW1.w,i.TtoW2.w);
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir( worldPos));
// bump应该是对 bumpMap的缩写
fixed3 bump = UnpackNormal(tex2D(_BumpMap,i.uv.zw));
bump.xy *= _BumpScale;
bump.z = sqrt(1.0 - saturate(dot(bump.xy,bump.xy)));//这个算法参考NormalMapTangentSpace_Texture的注释
//转换到世界坐标中计算方向
//TODO 这个乘法是如何实现的?依据什么公式?
//后续解释 首先,运算到这里,法线只是一个方向向量了,而这个矩阵本身就是法线所处的切线空间变换到世界空间的变换,所以这里只是变换法线到世界空间。
// 另外,变换矩阵的生效左乘结果和点乘公式的展开是完全一样的。参考手写图片。
bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump)));
//后面是光照和颜色的处理
fixed3 albedo = tex2D(_MainTex,i.uv).rgb * _Color.rgb;
//TODO 为什么要乘上环境光得颜色?如果不乘的话颜色会发白(环境光设置的颜色)
//后续解释,外部颜色和本身颜色的叠加(混合)使用乘法,各种不相干的颜色的叠加,使用加法。
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
//再回顾一下漫反射的计算公式 光线颜色 * 物体颜色 * dot(法线方向和光线方向)
//其实就是利用法线贴图扰乱法线,以达到反射时形成凹凸效果
fixed3 diffuse = _LightColor0.rgb * albedo *max(0,dot(bump,lightDir));
//再回顾一下Blinm光照模型公式
fixed3 halfDir = normalize(lightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0,dot(bump,halfDir)),_Gloss);
return fixed4(ambient + diffuse + specular,1.0);
}
变换矩阵的生效左乘结果和点乘公式的展开是完全一样的。PS:代码中提到的手写公式图片,原谅字丑。
书中主要是用渐变纹理来控制从亮处到阴影处的渐变颜色,实现类似插画的渲染风格。直接效果和代码,注意代码注释中写了对渐变纹理的理解。
效果-可以看到阴影渐变处微微泛红,对应我们添加的渐变纹理的颜色。
代码-渐变纹理控制阴影渐变颜色-Ramp_Texture.shader
fixed4 frag (v2f i) : SV_Target
{
//取出世界坐标
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir( i.worldPos));
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
//再回顾一下半兰伯特光照,半兰伯特是为了解决漫反射背面较暗的问题
//要记住,半兰伯特光照也是经验公式,其中的最常用0.5作为加和乘的系数,因为这样效果较好
fixed halfLambert = 0.5 * dot(worldNormal,worldLightDir) + 0.5;
// 计算漫反射颜色
// 要注意,这里不再严格的符合标准漫反射公式,而且由halfLambert来构建uv坐标,也是看到这里突然明白了渐变纹理的意思
// 所谓渐变纹理,其实是控制从光照亮处到光照阴影处的渐变颜色,比如从白色亮光部分逐渐过渡到红色的阴影。
// 而halfLambert系数,正是对漫反射光照强度从[-1,1]到[0,1]的映射,也证实了这一观点。
// 即光照越强的地方halfLambert生成的uv值取透明度更高的的贴图颜色,光照越弱的地方halfLambert生成的uv值取颜色更深的贴图颜色。
fixed3 diffuseColor = tex2D(_RampTex,fixed2(halfLambert,halfLambert)).rgb * _Color.rgb;
fixed3 diffuse = _LightColor0.rgb * diffuseColor;
//再回顾一下Blinm光照模型公式,Blinm高光是为了减少普通高光中的需要计算的反射方向r
fixed3 halfDir = normalize(worldLightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0,dot(worldNormal,halfDir)),_Gloss);
return fixed4(ambient + diffuse + specular,1.0);
}
什么是遮罩呢?简单来说,遮罩允许我们可以保护某些区域,使他们免于某些修改。那么遮罩纹理就是我们使用一个特殊的纹理,在这个纹理中填入一定的权重值,来控制某些区域的效果重一些,某些区域轻一些,或者某些区域完全屏蔽某些效果。
使用遮罩纹理的流程一般是:通过采样得到遮罩纹理的纹素值,然后使用其中某个(或某几个)通道的值作为掩码值来与某些表面属性进行相乘,这样当该通道的值为0时,可以保护表面不受该属性的影响。
代码没什么关键的,直接看工程中的Mask_Texture.shader吧。
立方体纹理一共包含了6张图像,这些图像对应了一个立方体的6个面,立方体纹理的名称也由此而来。对立方体纹理采样我们需要提供一个三维的纹理坐标,这个三维纹理坐标表示了我们在世界空间下的一个3D方向。这个方向矢量从立方体的中心出发,当它向外部延伸时就会和立方体的6个纹理之一发 生相交,而采样得到的结果就是由该交点计算而来的。
我们经常使用立方体纹理做天空盒子和环境映射。当它用于环境映射时,有一些缺点。
优点:实现简单快速,而且得到的效果也比较好。
缺点:
接下来说一下常用立方体纹理实现的各种效果。PS:由于多是实践的内容,所以这里只贴出效果和实现思路,具体的代码分到其他博客中单独贴出来。
这里会导向到其他博客 TODO!!!!
想要模拟反射效果很简单,我们只需要通过入射光线的方向和表面法线方向来计算反射方向,再利用反射方向对立方体纹理采样即可。其中要注意的是,反射方向提供了内置方法reflect来,传入负的观察方向和法线方向即可。
o.worldRefl = reflect(-o.worldViewDir,o.worldNormal);
Reflection.shader
//省略代码........ 完整代码请看https://download.csdn.net/download/lanazyit/13586309
_Cubemap("Reflection Cubemap",Cube) = "_Skybox" {}
//省略代码........ 完整代码请看https://download.csdn.net/download/lanazyit/13586309
// 光路可逆 ,所以可以通过观察方向和法线来确定光线方向
//TODO 但是这里为什么要将worldViewDir取反呢?
o.worldRefl = reflect(-o.worldViewDir,o.worldNormal);
//省略代码........ 完整代码请看https://download.csdn.net/download/lanazyit/13586309
//Cubemap的采样是使用一个穿过中心的方向向量与六个面的交点来采样的
fixed3 reflection = texCUBE(_Cubemap,i.worldRefl).rgb * _ReflectionColor.rgb;
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
fixed3 color = ambient + lerp(diffuse,reflection,_ReflectionAmount)*atten;
return fixed4(color,1.0);
//省略代码........ 完整代码请看https://download.csdn.net/download/lanazyit/13586309
折射效果主要是模拟下图的光线的物理折射效果,公式也是由内置的方法refract()实现。这个方法我们需要传入观察反方向,法线方向和物体的折射率。PS:物体的折射率,这是常用的物理常数,百度查找合适的折射率即可。
另外要注意,实际物理世界中,光线进入介质,再从介质出来,有两次折射。我们只模拟了一次折射,因为模拟消耗更少,效果也像模像样。
// 利用内置的折射方法计算cubeMap的sample向量
o.worldRefr = refract(-normalize(o.worldViewDir),normalize(o.worldNormal),_RefractionRatio);
效果
关键代码Refraction.shader 与 Reflection基本完全一样,只是替换了折射公式。
菲涅耳反射描述了一种光学现象,即当光线照射到物体表面上时,一部分发生反射,一部分进入物体内部,发生折射或散射。被反射的光和入射光之间存在一定的比率关系, 这个比率关系可以通过菲涅耳等式进行计算。
一个经常使用的例子是,当你站在湖边,直接低头看脚边的水面时,你会发现水几乎是透明的,你可以直接看到水底的小鱼和石子:但是,当你抬头看远处的水面时,会发现几乎看不到水下的情景,而只能看到水面反射的环境。这就是所谓的菲涅耳效耳效果。
而我们在做渲染是,会使用一些近似公式,比较有名的是
以下是使用Schlick菲涅耳近似等式实现的效果。注意边缘处是反射。
关键代码Fresnel.shader
fixed3 diffuse = _LightColor0.rgb * _Color.rgb * max(0, dot(worldNormal, worldLightDir));
//Cubemap的采样是使用一个穿过中心的方向向量与六个面的交点来采样的
fixed3 reflection = texCUBE(_Cubemap,i.worldRefl).rgb ;
//对应菲涅耳近似等式,即菲涅尔反射,来描述不同角度和距离的情况下,反射的比率
fixed fresnel = _FresnelScale + ( 1 - _FresnelScale) * pow(1-dot(worldViewDir,worldNormal),5);
fixed3 color = ambient + lerp(diffuse,reflection,saturate( fresnel))*atten;
现代的GPU允许我们把整个三维场景渲染到一个中间缓冲中,即渲染目标纹理(Render Target)。
Unity为渲染目标纹理定义了一种专门的纹理类型--渲染纹理(Render Texture)。
在Unity中使用渲染纹理通常有两种方式:
镜子效果就是,在背面放个相机,然后把相机照的到结果渲染到Render Texture上,只不过输出时要注意,翻转x轴,因为镜子是左右颠倒的。其他没啥好说的。看Mirror.shader
玻璃效果,先使用GrabPass抓取当前的渲染结果。然后对该结果进行扰乱(模拟折射)后放到玻璃物体上,模拟透过玻璃看到后面物体的折射效果。
需要注意的是,在使用GrabPass的时候,我们需要额外小心物体的渲染队列设置。正如之前所说,GrabPass通常用于渲染透明物体,尽管代码里并不包含混合指令,但我们往往仍然需要把物体的渲染队列设置成透明队列(即"Queue" "Transparent")。 这样才可以保证当渲染该物体时,所有的不透明物体都已经被绘制在屏幕上,从而获取正确的屏幕图像。
关键代码GlassRefraction.shader。
//省略代码........ 完整代码请看https://download.csdn.net/download/lanazyit/13586309
//这里需要注意,Queue和RenderType是不相同的东西,Queue来控制渲染队列,RenderType是为了控制着色器替换时可以被正确的渲染
//TODO 那么着色器替换(Shader Replacement)又是什么呢 PS:13章会有答案
Tags { "Queue" = "Transparent" "RenderType"="Opaque" }
GrabPass {"_RefractionTex"}
//省略代码........ 完整代码请看https://download.csdn.net/download/lanazyit/13586309
//对已经GrabPass抓取到的屏幕图像的采样坐标。ps:ComputeGrabScreenPos帮我们做好了不同平台的差异性处理
o.scrPos = ComputeGrabScreenPos(o.pos);
//省略代码........ 完整代码请看https://download.csdn.net/download/lanazyit/13586309
// 这里做出的折射效果与前面Refraction的不同,这里是直接通过对屏幕颜色采样,然后扭曲,得到的一个近似折射的效果
// Refraction则是使用折射率,折射角,观察角度等一系列参数模拟出的物理折射
// 需要提醒注意的是,这里bump是切线空间下法线
float2 offset = bump.xy * _Distortion * _RefractionTex_TexelSize.xy;
// 或许前面这个代码,加个括号更好理解(_Distortion * _RefractionTex_TexelSize.xy),这两者相乘,意思是_Distortion=1,则在贴图上移动一个纹素(或单位像素)
// float2 offset = bump.xy * (_Distortion * _RefractionTex_TexelSize.xy);
i.scrPos.xy = offset + i.scrPos.xy;//通过纹理的坐标的采样偏移,来模拟折射
//省略代码........ 完整代码请看https://download.csdn.net/download/lanazyit/13586309
使用数学公式渲染一些特殊的几何纹理,如图上的效果。内容不多,不再整理。
实测Unity2020已经不支持程序材质。不再整理。