作者:王子饼干 ——镇江虹视游戏科技有限公司联合创始人、《多洛可小镇》制作人
原文链接:UnityShader入门精要笔记5:基础光照模型A
原文发布时间:2020年4月2日
文章类型: 授权转载
我讨厌书,因为书只教人谈论自己不懂的东西。—— 让·雅克·卢梭
笔记当前使用的Unity版本:“2019.3.3”
笔记当前Unity最新的版本:“2020.1.0.Alpha 25”
本篇要讲的是如何实现最基础的光照模型。包括光照模型的原理和一些实现的代码,以及对现有的方法的一些基本优化手段。
关于渲染是什么,其实从不同的角度来说,从不同的职业来说,从不同的技术层面来说,有各种各样的描述方式,虽然它们众说纷纭,但是其实都是围绕了一个核心点。而我觉得,学习着色器的第一点,就是捡取一条最佳的描述方式。
我希望在阅读这篇笔记的人,可以记住下面这种描述方式。
渲染就是:“用一个函数来确定一个模型表面,所有像素点的颜色。”
并不是说这种描述方式是最佳的,但是它无疑是最适合你去理解着色器的手段。
所谓的函数,其实也就是我们的片元着色器,但重点往往不是它是什么,而是它如何实现。我们在之前的一个章节中实现过一个名为SimpleTest的着色器,它让模型表面变成了非常单一的纯色。
我们可以再观察一下它的片元着色器。
fixed4 Pixel():SV_TARGET{
//计算模型表面每个像素值的颜色
return _BaseColor;
}
我们可以发现,它其实并没有做任何的计算,只是单纯的把材质输入面板的颜色值返回了。这么做无异于下面的做法
//C
int Pixel(int color){
//根据传入的参数来做一定的计算
return color;
}
其实如果非要说的话,这样确实也算是计算了。但是这种计算并不会带来任何效果。不过它至少让我们知道了一件事,只要我们知道某种原理,某种规律,就可以用它实现一个函数。来帮助我们实现某种效果。如果你知道了这件事,那么我要对你的理解力点个赞。
这是至关重要的,着色器的语法只是一种实现载体,重要的是算法如何实现。不论是着色器编程,还是使用普通的编程语言来编程。算法永远是最核心的东西,它是数学的,它是自然的,不受任何约束的。可以被各种语言所表达的。
我们现在要回到初中的物理课堂,再来了解一下,我们是如何看到这个世界的。它其实没有那么复杂,我们也不需要对光这个概念有什么深入的认知。
首先我们要知道,我们人眼看到一个物体是因为这个物体表面的光线反射到了眼睛中,被瞳孔接收到,但是并不是所有的物体都可以自己发射光线,大部分情况下,我们看到这个物体,是由于它进行了光线的反射。
也就是说,我们要有一个光源,才可以看到周围的东西。如果没有光源,那么物体就无法反射光,我们也就看不到它们了。在现实生活中,这种事情我们习以为常了,现实生活中的光源很多,太阳,月亮(月亮其实也是反射太阳的光来着),日光灯,路灯,台灯,手机屏幕光。那么我们要了解的第一件事就是,物体是如何进行反射的。
通过这张图,我们了解到一个事实,要计算出反射光线,我们必须要有入射光线的入射角度。(在线性代数和着色器中,我们更应该称为向量或者方向)才可以计算出反射光线的出射角度。Oh!等等,并不是所有的表面都是水平的,所以,我们还得知道表面的法线。
因为我们需要两个重要参数:
这确实是一个头疼的问题,只有反射光线的方向我们也很难知道如何去计算一个某一个像素点的位置。这其实并不是我们该头疼的问题,而是计算机图形学的学者该头疼的问题。所以经过漫长的研究和技术的积累,一些和光照模型有关的论文逐渐被发表出来。其中最著名的就是兰伯特定律(Lambert's law)也称“比尔-朗伯定律(Beer-Lambert law)”
兰伯特定律:
漫反射光的强度近似地服从于Lambert定律,即漫反射光的光强仅与入射光的方向和反射点处表面法向夹角的余弦成正比。
通过Lambert我们可以计算模型的漫反射光线,虽然只是漫反射的光照模型,但是作为入门技术来说已经算是很不错的前进了。(如果读者不明白什么是漫反射和高光反射可以去百度或者谷歌了解)
所以我们需要对兰伯特定律做一个深入的分析。我们可以通过兰伯特定律写出如下公式(虽然严格来说,是先有公式再有描述)
其中diffuse(漫反射)是漫反射光线的强度,也就是我们要计算的东西,而I是光源的光线强度,θ是入射光线和模型表面法线的夹角。当然啦,我们其实很难获得这个夹角,但是通过线性代数我们可以知道。
其中两向量的乘积除以两向量模的乘积的商其实就是两向量标准化后的乘积,当然啦,所谓标准化就是指让向量中所有的分量都除以该向量的模。正如上面的公式那样。
有了上面这些公式,我们可以将它们整合在一起。用于计算漫反射光线
其中L是光源的向量,N是法线向量,I是光源强度。
看起来我们可以通过上面的公式来计算出模型表面的颜色,但是真的如此吗?仔细思考一下,夹角的余弦值会在夹角超过90°的时候变为负值
而正常来说,我们计算的是颜色的亮度,最后是表示为一个Color类型的变量,最暗也就用0来表示,怎么可能会出现负值呢?这里其实就是因为兰伯特定律并不是完全符合现实世界的规律的,只是一个近似的描述手段。我们可以趁这个机会再次阅读一下兰伯特定律
兰伯特定律:
漫反射光的强度近似地服从于Lambert定律,即漫反射光的光强仅与入射光的方向和反射点处表面法向夹角的余弦成正比。
因此如果要使用兰伯特定律来进行光照计算,我们还必须人为的将计算为负值的像素点设为0,通过max函数。
至此,这个公式就可以开始使用了。
从原理到实践,这就是学习。既然有了上述的公式,我们现在再考虑的就是,如何获得公式中所需要的参数。
在上一篇笔记中我们似乎说过如何获得法线,这里我们可以回顾一下,模型表面的法线向量可以通过 NORMAL语义块来获得;在我们构建顶点着色器的输入时,我们必须要指定NORMAL将模型的法线填充到一个变量中,如下所示
struct vertexInput{
float4 vertex: POSITION;
float3 normal : NORMAL;
//通过NORMAL语义块将模型的法线信息填充到normal变量中
};
获取光源的强度和光源方向稍微有些复杂,而且这里其实要用户去操作的部分不多,因而很难感受到它们实际的存在性。
光源信息其实都定义在一个叫做“Lighting.cginc”的文件中,这个文件就类似于是C/C++的头文件一样,里面有很多Unity已经定义好的变量和函数,我们只要通过#include指令就可以使用它们了。
其中我们所需要的光源信息如下:
当渲染开始的时候,Unity会事先把所有光源的参数填充到目标变量中去。不过这里也带来了很多其他的问题,第一,Unity中定义了两种主要类型的光源,
它们之间的区别就非常大,平行光没有光照衰减,(也就是无论平行光处于什么位置,它的强度都是一样的,通常模拟太阳),而点光源和聚光灯则有光照衰减,(当距离离得远了,光照强度就会降低,通常模拟普通光源)。
但是我们都是通过_LightColor0变量和_WorldSpaceLightPos0来获取他们的信息,Unity是如何处理的呢?
如标题所示,Unity通过一种叫做标签的东西来定义一个pass所需要的光照信息。当然,标签除了定义光照相关的变量,还可以定义很多其他的配置和命令。这些配置让Unity知道自己应该把什么样的参数放到什么样的变量中去。
对于不同的标签,它们可能有多个不同的值。而我们此处主要使用的标签是LightMode
LightMode决定了光照模式,该标签可用的值我们暂时只需要知道两个,ForwardBase和ForwardAdd,
其中当我们把LightMode设为ForwardBase,那么Unity就知道我们需要平行光的光源信息,接下来,_LightColor0也就代表平行光的光源强度,_WorldSpaceLightPos也就代表平行光的方向
ok既有了公式,又有了参数,我们就可以开始计算了,当然,前面说的这些标签,在哪设置,该怎么设置。在第四节可以看到
说实话,我们确实既可以在顶点着色器中计算光照,又可以在片元着色器中计算光照。此时我们需要进一步的了解它们的本质,顶点着色器是针对顶点的,比如一个模型有8个顶点,就会计算8次,而片元着色器是每个像素计算一次,比如有xxx个像素,就会计算xxx次。所以从理论上来说,顶点着色器计算的结果更为粗糙,片元着色器计算的结果更为细腻,但是相对的,片元着色器要计算的量也更为繁重。所以我们尽量不要把太多的复杂的计算放到片元着色器中。
在顶点着色器中计算光照,也可以称为逐顶点光照,在片元着色器中计算光照,也可以称为逐像素光照。这么说不够直观,我们不妨两者都试一下。
我们新建一个新的着色器,然后删除所有的代码开始自己编写。当然,前面框架的部分大家是可以自己完成的,我不知道有没有人把上一篇笔记中那个最简单的着色器写了十遍,但是我自己是写了这么多遍的。(很多人都在问我有没有什么速成的学习方法,其实练习是最速成的手段,它比任何高深的讲解都要有效,你可以掏出6个小时的时间,来不断的从头开始重新编写一个着色器,这6个小时什么也不做,就写着色器,相信我!这是最最速成的手段,如果不信可以试一试,即使最后你觉得没有效果,也不会让你损失多少)
Shader "UShaderMagicBook/PerVertexLambertLight_0"{
/*不带有颜色参数的兰伯特光照模型*/
Properties{
/* 暂时不需要任何输入 */
}
SubShader{
/*如果是在SubShader中设定Tags,则在所有的Pass中通用
但是并不是所有的标签都可以在SubShader中使用,就比如LightMode是针对pass的*/
pass{
Tags{"LightMode"="ForwardBase"}
/* 在pass块中设定的的标签只能在Tags中有效,如果pass中出现了和SubShader
中同样的标签,则会覆盖SubShader中的标签。
此处我们使用了ForwardBase,也就是这个光照模型只对平行光有效*/
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
//设定顶点着色器的入口和片元着色器入口,类似于设定主函数一样
#include "Lighting.cginc"
/*引用Unity自己定义的着色器头文件(并非真实的头文件,只是方便理解这么说而已),
你也可以编写自己的着色器头文件,而Unity内置的头文件可以在~/Unity3d/Editor/Data/CGIncludes中找到*/
struct vertexInput{
float4 vertex : POSITION;
float3 normal : NORMAL;
//通过NORMAL语义来获得模型表面发线
//注意这个法线是模型空间的法线,要计算的话,需要先转到世界空间
};
struct vertexOutput{
float4 pos : SV_POSITION;
float4 color : COLOR;
//由于在顶点着色器中计算光照,计算完后要传递给片元着色器
//所以使用COLOR语义来存储颜色信息
};
vertexOutput Vertex(vertexInput v){
//顶点着色器的的输入一定要用v作为变量名,用o作为输出名,
//理由等到时候自然就明白了
/*计算兰伯特光照模型*/
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex); //顶点着色器一定要做的事情
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed3 worldNormalStd = normalize(worldNormal);
/*第一行主要是把模型空间的法线通过Unity的内置函数
UnityObjectToWorldNormal函数转到世界空间中
第二行主要是把法线给标准化*/
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
/*由于平行光它没有位置概念,只有方向概念,(因为它不论在哪,强度都一样,所以只有方向)
因而它的位置就可以代表它的方向,(在线性代数中,一个顶点减去原点坐标(0,0,0),就是从原点
指向它的一条方向),该部分如果理解不了,说明需要补一补线性代数或者立体几何了*/
fixed3 diffuse = _LightColor0.xyz * max(0,dot(worldNormal,worldLightDir));
o.color = fixed4(diffuse,1.0);
//注意o.color是fixed4类型
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
/*不需要计算任何东西*/
return i.color;
}
ENDCG
}
}
}
在着色器编写完成后,我们新建一个材质,然后把材质的着色器换成我们刚才编写的这个着色器。然后创建一个胶囊体,把新的材质赋给胶囊体,就可以得到一个拥有基础光照的模型了。
为了和逐顶点的光照做一个对比,我们可以继续,编写一个拥有逐片元光照的模型,同样的算法,同样的输入,只不过一切都是在片元着色器中完成。导致的一些不同的就是,我们需要把法线信息传递到片元着色器中。
Shader "UShaderMagicBook/PerFragmentLambertLight_0"{
Properties{}
SubShader{
pass{
Tags{"LightMode"="ForwardBase"}
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#include "Lighting.cginc"
struct vertexInput{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct vertexOutput{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
//这里的texcoord仅仅是用作储存世界法线信息而已
};
vertexOutput Vertex(vertexInput v){
/*在顶点着色器中进行坐标的转换*/
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
fixed3 worldNormal = normalize(i.worldNormal); //将法线信息标准化
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
fixed3 diffuse = _LightColor0.xyz * max(0,dot(worldNormal,worldLightDir));
//_LightColor0中同时储存了平行光(在ForwardBase模式下)的光源颜色和强度
return fixed4(diffuse,1.0);
}
ENDCG
}
}
}
然后我们也新建一个材质赋予刚才编写的着色器,新建一个胶囊体,与之前的胶囊体做一个对比
可以看到,在明暗交界的边缘处逐像素的光照过渡更为平滑一点。
至此,我们本篇笔记就结束了,其实原书中还讲了其他的模型,但是我觉得到这里就可以暂时作为一个阶段了,这个阶段我觉得最重要的事情并不是让读者学会了如何计算兰伯特光照,也不是着色器的语法或者是一些配置。而是去学会怎么用着色器的思维来解决一个问题,即,我要实现一个效果,原理是什么,怎么获取输入,怎么计算。
由于很多原理都是需要比较深的数学知识的,所以数学是非常重要的。这也是程序员的分水岭之一。比如我们要实现水面效果,该怎么实现?原理是什么呢?有的时候你可能需要自己做一些研究,但是大多数时候,我们可以借鉴别人的研究成果。但并非完全抄袭,而是学习它的原理,然后自己改造。比如,我们去知网上搜一些关于图形学,水面效果的论文。就可以找到一些不错的文章。当然啦,更好更多的效果还是要看一些国外的论著。不过在这之前,还是让我们先静下心来,好好的把《UnityShader入门精要》先学习完成,了解UnityShader的基础之后,再去考虑那些东西。
该篇笔记所编写的所有代码,都上传到了我的github,大家可以在下面的链接找到