作者:王子饼干 ——镇江虹视游戏科技有限公司联合创始人、《多洛可小镇》制作人
原文链接:UnityShader入门精要笔记13:高级光照C
原文发布时间:2020年3月18日
文章类型: 授权转载
纸上得来终觉浅,绝知此事要躬行。————陆游《冬夜读书示子聿》
笔记当前使用的Unity版本:“2019.3.3”
笔记当前Unity最新发行版:“2019.3.5”
笔记当前Unity最新的预览版本:“2020.1.0.Alpha 25”
这是第9章最后一个部分了,我们已经解决了两个主要的问题,第一是为模型增加点光源光照,第二是光照衰减的原理,(为什么是原理呢?因为我们之前所说的那些东西统统都可以使用一个宏来计算,不需要太复杂),这节我们会解决掉最后一个问题,即阴影。
阴影有很多不同的计算方式,而在Unity当中,我们一般会采用阴影映射纹理(ShadowMap)来实现阴影效果。它非常好理解,但是我们需要先理解另外一个东西,即深度纹理(Depth Texture)。
深度纹理是一张纹理,上面记录了从摄像机出发,距离摄像机最近的点的距离,距离越近,颜色越深,否则就越亮。(因为距离用于表示颜色,距离越远,值越大,颜色就越亮)下面就是一张深度图。
有非常多的效果都可以通过深度图来实现,比如相交高亮,方向雾效,扫描效果,水体的深度效果等等。
那么这和阴影映射纹理有什么关系呢?本质上,ShadowMap就是一个深度纹理,我们要知道深度纹理是相对于摄像机来说的,然而ShadowMap是针对光源来说的,所以,它们本质上一模一样,只不过一者是距离摄像机远近,一者是距离光源远近。
那么ShadowMap和阴影是怎么联系在一起的呢?毕竟它是深度图,怎么才能和阴影搭上关系,其实这就要说到摄像机了。当我们开启ShadowMap的时候,其实本质上就是把光源当成摄像机,摄像机就类似于人眼,对于一个场景来说,如果有一些东西是摄像机所看不到的,在看不到的地方,就会产生阴影。只不过呢,这里的摄像机本质上是一个光源。
总结一下,就是,如果光源看不到的地方,就会产生阴影。为了描述光源看不到的地方,就有了ShadowMap,阴影映射纹理。
ShadowMap是一个深度纹理,但是它不是说有就有的。我们需要构建一套渲染逻辑,为每个光源生成一个ShadowMap,这个本质上难度是很大的,只不过Unity为我们做好了封装,使得我们可以方便使用。(ps:有的时候仅理解原理也只能是片面的,但是总比不理解要好的多)
Unity准备了一个专门的Pass来计算阴影映射纹理,这个Pass会把所有的信息都更新到ShadowMap中,这样我们就可以通过对ShadowMap采样来获取深度信息了。
这个Pass需要我们手动指定一下,我们编写一个Pass,然后将其Tag设为“ShadowCaster”就可以产生一个ShadowMap,这个Pass会把它所捕捉到的深度信息作为这个pass的输出。
在传统的阴影映射纹理技术中,我们会在正常渲染的Pass中把顶点位置变换到光源空间下,以得到它在光源空间中的三维信息。然后,我们使用xy分量对阴影映射纹理进行采样,因为阴影映射纹理的深度值是距离光源最近的深度值,所以如果我们采样到的信息比这个顶点的深度小,说明这个顶点被阴影所覆盖。(ps:理解的轻松一点,这个比较就是在判断一个顶点是不是被挡住了)
但是在Unity5中,Unity使用了不同于传统技术的阴影映射技术,即屏幕空间的阴影映射技术(Screenspace Shadow Map),由于这个技术需要显卡支持MRT,因而并不是所有的平台都支持这种技术。
ok,接下来我们开始实现阴影。在此之前,我们需要让物体接受阴影,投射阴影。如下图所示
并且开启光源的阴影。
ShadowMap需要一个额外的Pass来生成,代码如下:
pass{
// this pass is a shadow map pass
Tags{"LightMode"="ShadowCaster"}
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#pragma multi_compile_shadowcaster
#include "UnityCG.cginc"
struct vertexOutput{
V2F_SHADOW_CASTER;
};
vertexOutput Vertex(appdata_base v){
vertexOutput o;
TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
return o;
}
float4 Pixel(vertexOutput i):SV_TARGET{
SHADOW_CASTER_FRAGMENT(i)
}
ENDCG
}
这里有一堆乱七八糟的宏,我们只要知道它的输出是一张ShadowMap就可以了。
让物体接受阴影,其实也就是让物体表面的颜色和阴影进行混合。这里我们也是由特定的宏来实现的。上面那个pass先写出来,然后我们增加一个计算平行光的pass如下
pass{
Tags{"LightMode"="ForwardBase"} //计算平行光
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#pragma multi_compile_fwdbase
#include "Lighting.cginc"
#include "AutoLight.cginc"
struct vertexInput{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
struct vertexOutput{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2;
SHADOW_COORDS(3)
//Shadow_Coords声明一个uv坐标,只不过是对ShadowMap采样。
};
sampler2D _MainTex;
fixed4 _BaseColor;
float _Gloss;
vertexOutput Vertex(vertexInput v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
o.uv = v.texcoord;
TRANSFER_SHADOW(o) //转换阴影的uv坐标
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
fixed shadow = SHADOW_ATTENUATION(i); //获取阴影,我们需要将这个值和漫反射与高光反射混合在一起
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 albedo = tex2D(_MainTex,i.uv).xyz * _BaseColor.xyz;
fixed3 diffuse = _LightColor0.xyz * albedo * saturate(dot(lightDir,worldNormal));
fixed3 halfDir = normalize(viewDir + lightDir);
fixed3 specular = _LightColor0.xyz * pow(saturate(dot(halfDir,worldNormal)),_Gloss);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 atten = 1.0;
return fixed4(ambient + (specular + diffuse) * atten * shadow,1.0);
}
ENDCG
}
ok,和一般的计算平行光相比,我们增加了三个宏,分别是
我们可以在平行光下进行简单的测试。
看起来还不错,这个三个宏就是计算阴影的三板斧。记住它们三个就完事了,另外它们的定义是在AutoLight.cginc中,所以在使用他们之前记得把AutoLight.cginc包含进来,至于为什么要有这么多的宏,主要是因为在编译的时候会出现很多变体,比如某个模型不投射阴影(它不会被捕捉到ShadowMap中),某个模型无法接受阴影(它不会和shadow混合),某个光源没有开启阴影(该光源的shadow值始终为1)。有诸多的模型,诸多的光源,诸多的情况,为了正确且合理的对每个状况进行判断,就有了诸多的宏处理。
在点光源和聚光灯的情况下,和平行光差不多,但是有一个特别要注意的点。特别特别要注意的点,即
在AdditionalPass中原本要增加的编译条件#pragma multi_compile_fwdadd需要改为#pragma multi_compile_fwdadd_fullshadows
在玩重装机兵的时候,颇有一种,下了战车我唯唯诺诺,上了战车我重拳出击的感觉。写Shader就有这种感觉,Unity就是一辆战车。它所提供的诸多工具可以让我们不用记住那么麻烦的东西。
我们只需要调用
UNITY_LIGHTATTEN_ATTEN宏
就可以同时计算光照衰减和阴影啦。
我们可以稍微看一下这个宏是怎么定义的。
这个是针对点光源的UNITY_LIGHT_ATTENUATION,可以看见,它同时计算了shadow和atten,并且把它们俩乘在一起返回了。
我们需要写一个destName,destName是一个没有定义的参数,实际上就是你要告诉它,你打算把shadow*atten这个值保存到什么变量名中,(有点类似于C语言传递引用参数,然后被函数覆写一样),input就是你的片元着色器的输入变量名,我们一般会写i,worldPos顾名思义就是模型世界坐标位置啦。
我们这里给一个使用该宏来计算衰减和阴影的代码案例
Shader "UShaderMagicBook/ShadowMap_normal"{
Properties{
_MainTex("Main Texture",2D) = "white"{}
_BaseColor("Base Color",Color) = (1.0,1.0,1.0,1.0)
_Gloss("Gloss",Range(8.0,200)) = 20.0
}
SubShader{
pass{
// this pass is a shadow map pass
Tags{"LightMode"="ShadowCaster"}
CGPROGRAM
#pragma vertex VertexShadow
#pragma fragment PixelShadow
#pragma multi_compile_shadowcaster
#include "UnityCG.cginc"
struct vertexOutput_shadow{
V2F_SHADOW_CASTER;
};
vertexOutput_shadow VertexShadow(appdata_base v){
vertexOutput_shadow o;
TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
return o;
}
float4 PixelShadow(vertexOutput_shadow i):SV_TARGET{
SHADOW_CASTER_FRAGMENT(i)
}
ENDCG
}
pass{
Tags{"LightMode"="ForwardBase"}
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#pragma multi_compile_fwdbase
#include "Lighting.cginc"
#include "AutoLight.cginc"
struct vertexInput{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
struct vertexOutput{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2;
SHADOW_COORDS(3)
//Shadow_Coords声明一个uv坐标,只不过是对ShadowMap采样。
};
sampler2D _MainTex;
fixed4 _BaseColor;
float _Gloss;
vertexOutput Vertex(vertexInput v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
o.uv = v.texcoord;
TRANSFER_SHADOW(o) //转换阴影的uv坐标
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 albedo = tex2D(_MainTex,i.uv).xyz * _BaseColor.xyz;
fixed3 diffuse = _LightColor0.xyz * albedo * saturate(dot(lightDir,worldNormal));
fixed3 halfDir = normalize(viewDir + lightDir);
fixed3 specular = _LightColor0.xyz * pow(saturate(dot(halfDir,worldNormal)),_Gloss);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos);
return fixed4(ambient + (specular + diffuse) * atten,1.0);
}
ENDCG
}
pass{
Blend One One
Tags{"LightMode"="ForwardAdd"}
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#pragma multi_compile_fwdadd_fullshadows
#include "Lighting.cginc"
#include "AutoLight.cginc"
struct vertexInput{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
struct vertexOutput{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2;
SHADOW_COORDS(3)
//Shadow_Coords声明一个uv坐标,只不过是对ShadowMap采样。
};
sampler2D _MainTex;
fixed4 _BaseColor;
float _Gloss;
vertexOutput Vertex(vertexInput v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
o.uv = v.texcoord;
TRANSFER_SHADOW(o) //转换阴影的uv坐标
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 albedo = tex2D(_MainTex,i.uv).xyz * _BaseColor.xyz;
fixed3 diffuse = _LightColor0.xyz * albedo * saturate(dot(lightDir,worldNormal));
fixed3 halfDir = normalize(viewDir + lightDir);
fixed3 specular = _LightColor0.xyz * pow(saturate(dot(halfDir,worldNormal)),_Gloss);
UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos)
return fixed4((specular + diffuse) * atten,1.0);
}
ENDCG
}
}
}
后面就不用写那么一大坨丑陋的代码了~最终效果如下
Unity内置的半透明着色器是不支持投射阴影和接受阴影的,就好像这个物体不存在一样。然而如果我们通过一些设置强制它产生和接受阴影,它又会变得像是一个不透明物体一样,因为玻璃或者水体投射的阴影可能是存在焦散效果的。但是如果真的严格按照真实的思维来考虑,那么性能上面可能会非常差。
但是如果真的需要半透明物体的阴影,我们可以采用很多不同的方式来近似,这个时候我们只会开战车是没有用的,有很多危险的坑道战车是开不进去的,需要你设身处地的去战斗。研究原理,研究本质,改造和利用。这也是为什么高度封装的API是一把双刃剑的理由。也对应了标题下的“纸上得来终觉浅,绝知此事要躬行”。
由于原书所展示的半透明物体阴影效果和不透明物体一样,不符合逻辑,因而这里略过不讲。后面会有专门的章节来针对半透明物体的阴影。
ok,这是高级光照的最后一部分了,希望能对大家有一定的帮助,虽然说是高级光照,其实是相对的,我们所采用的兰伯特漫反射模型与Blinn-Phong高光模型在全局光照模型当中被称为“原始着色法”,是相对来说比较落后的着色方式,不过大部分时候它们已经足够满足要求了,毕竟好看的画面也需要一个好的美术和一个好的灯光师的搭配才能做出来。
本次使用到的所有代码都已经上传到了我的github,在下面的链接中可以找到。如果你喜欢这个系列的文章,欢迎关注该专栏,或者给我的github贡献一颗⭐,感谢阅读。