别问我为什么名字这么中二,cat-likeCoding 这么叫的
本文主要参考 cat-likeCoding 的 Rendering 的第四章
首先,我不会讲所有的内容,或者至少不在这篇里讲,所以需要有一些前置知识:
首先要计算光照,就必须得有法线,首先在 Unity 里得到顶点法线
1struct VertexData {
2 float4 position : POSITION;
3 float3 normal : NORMAL;
4 float2 uv : TEXCOORD0;
5};
注意这个法线是模型空间的,因此我们要进行一次空间转换来得到世界空间下的法线
直接乘以转换矩阵,然后再归一化
1o.normal = mul(unity_ObjectToWorld, float4(v.normal,0));
2o.normal = normalize(o.normal);
然后我们把(-1,1)的向量值归一化到(0,1)来用颜色表示
1col.xyz = i.normal*0.5+0.5;
看起来似乎对了
但是如果我们缩放一下,就会出现问题。红色圈出的区域法线的方向一点都不均匀,看起来很不对劲
这是因为,法线的这种转换,对于齐次缩放是没问题的。
但是对于非齐次缩放,我们的法线变换就会变得很尴尬,如下图所示
因此我们需要一个转换,把这个非齐次缩放消除掉,得到正确的法线。
首先我们知道,对于一个点,从模型空间转到世界空间,不管怎么变都可以算作是很多组旋转 R,位移 P,缩放 S 这三种矩阵的依次相乘,把这个整体的变换矩阵写出来就是:
这是点,如果是向量,我们不需要考虑位移,那么应该是这样
刚才我们的操作里,正是因为这个缩放矩阵 S 的存在,才导致我们的法线变形。我们只想要旋转,想要消除缩放的影响,因此我们期待的法线的变换矩阵应该是这样
接下来就是神奇的线性代数魅力时刻,我不会给出推导。
但是结论来说,对于旋转 R,位移 P,缩放 S 三种矩阵来说,我们都有办法来简单求到他的逆矩阵:
对于位移 P,直接将位移的量变成负数即可,简单易懂
对于缩放 S,它其实是一个对角矩阵,变换起来也非常简单
对于旋转 R,它的逆矩阵等于自己的转置矩阵
同时,各大引擎基本也会提供世界空间到模型空间的变换矩阵,其实就是前面变换
的逆矩阵,我们可以写作:
然后根据线性代数知识,我们幸运得到了下面的东西:
为什么会有这样的结论?我们观察下旋转矩阵 S,由于它是对焦矩阵,所以它的转置与自己相同;而对于旋转矩阵,他的转置就是自己的逆,因此求逆两次以后,重新得到自身。
然后巧的是,这就是我们需要的变换 N
因此我们得到结论:
从模型空间到世界空间的法线变换,要用世界空间到模型空间变换矩阵的转置矩阵
代码如下
1o.normal = mul(transpose((float3x3)unity_WorldToObject), float4(v.normal,0));
2o.normal = normalize(o.normal);
这下明显法线非常连续了,不再会有奇怪的断层
我用的是 Urp,默认 Unity 自建的 shader 基本都是 cg 语言。但是 urp 似乎 hlsl 更友好一点。因为不知道为什么有时候会在 CG 里拿不到某些光源的信息,因此需要读者自行把 cg 对应的代码改成 hlsl 的
首先要 Include 光照相关的头文件
1#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
2#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
获取光照的信息,
相关函数定义在"Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
1Light mainLight = GetMainLight();
2float3 lightColor = mainLight.color;
3float3 lightDir = mainLight.direction;
引入光源,光源简单的点乘一下法线,注意要防止点乘结果小于 0
1col.xyz = _LightColor0 * max(dot(lightDir, i.normal),0);
似乎没什么问题
贴图也映射上去,大概这样子
1// sample the texture
2float3 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv).rgb;
镜面反射,也就是高光,不仅仅与光源方向,表面法线有关,还与观察方向有关
因此我们需要先拿到相机位置和表面的世界坐标
我们先添加世界坐标在 v2f 结构中
1struct v2f
2{
3 float2 uv : TEXCOORD0;
4 float4 vertex : SV_POSITION;
5 float3 normal : NORMAL;
6 float3 worldPos: TEXCOORD1;
7};
如果你不是用 hlsl 写,是用 cg 写的话,会有一个搞笑的问题
非常睿智的 Unity 会在你不指定注解的情况下,给你添加一个宏
比如我的定义只写了 float3 worldPos,在编译后他会添加一个莫名其妙的宏
// Upgrade NOTE: excluded shader from DX11_; has structs without semantics (struct v2f members worldPos)_ #pragma exclude_renderers d3d11
最搞笑的还是如果你后面加了注解,这个宏也不会消失,如果你刚好用的是 DX11,那么你会一直得到 shader 的奇妙报错
Unlit/BaseLight shader is not supported on this GPU (none of subshaders/fallbacks are suitable)
哈哈,神奇
计算反射方向
1float r = reflect(-lightDir, i.normal);
计算观察方向
1float3 viewDir = normalize(-i.worldPos+_WorldSpaceCameraPos);
然后点乘起来,并且用一个参数 pow 之后控制,让高光更集中一点
1float3 spec = lightColor * pow(DotClamped(r, viewDir) ,_Gloss*100.0);
你就会得到一个炫酷的光照效果(好吧,也许没那么炫酷)
这里有个小细节要说一下
因为我们的法线是顶点法线通过转换之后,再通过片元着色器的自动插值得到的。(关于顶点着色器到片元着色器如何插值推荐看这个,还是非常推荐自己实现一个简单的软渲染器熟悉一下的)
我们前面计算法线是这么算的:
1v2f vert (appdata v)
2{
3 o.normal = mul(transpose((float3x3)unity_WorldToObject), float4(v.normal,0));
4 o.normal = normalize(o.normal);
5}
然后我们会发现,法线似乎不够均匀
(注:我为了强调高光特意调大了高光的强度)
这是因为我们的法线的归一化是在顶点着色器中做的,然后片源着色器对归一化的法线进行了插值
这样导致的问题是:归一化的法线插值以后,并不一定是归一化的。法线会看起来更“块状”
解决方法也很简单,我们不再顶点中做归一化,而是由片源着色器自动插值后,在片元着色器中做归一化
1v2f vert (appdata v)
2{
3 o.normal = mul(transpose((float3x3)unity_WorldToObject), float4(v.normal,0));
4 // o.normal = normalize(o.normal);
5}
6
7half4 frag (v2f i) : SV_Target
8{
9 i.normal = normalize(i.normal);
10}
这时候我们的法线就很漂亮啦
鼎鼎大名的光照模型,主要的区别在于:用半角向量和法线的点积代替反射光和视角的点积
半角向量其实就是观察向量和光照方向的平均值
大概的图示
其中
明显 bling-Phong 的求解效率更高(显而易见,求反射比求和以后归一化更加复杂)
但是它有个显著的缺点,就是会在光照背面产生伪高光
这个问题是不太好解决的,但是没关系,后面我们就会换光照模型
其实这部分推导任何资料都能找到,但是这里重要的其实是思考过程。即:我们依据哪些日常的物理现象通过合理的简化,换成光照模型中的参数和概念
在图形学 杂项之一中,我们讨论了 BRDF 的思路,由简单到复杂。我希望这部分也同样,重点是:为什么这样做。
我们的反射颜色取决于光照颜色,目前来说
1float3 spec = lightColor * pow(DotClamped(r, viewDir), _Gloss * 100.0);
但是事实上,镜面反射的颜色和强度还取决于材质:非金属的镜面反射明显很弱,而金属的镜面反射很强,会有很集中的高光。
因此我们再引入一个值,来控制高光的颜色。这并不仅仅只是为了给它乘一个颜色,而是把它作为材质本身的属性来考虑
1 Properties {
2 _SpecularTint ("Specular", Color) = (0.5, 0.5, 0.5)
3 }
4
5 …
6
7 float4 _SpecularTint;
8
9 …
10
11 float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
12 …
13
14 float3 halfVector = normalize(lightDir + viewDir);
15 float3 specular = _SpecularTint.rgb * lightColor * pow(
16 DotClamped(halfVector, i.normal),
17 _Smoothness * 100
18 );
19
20 return float4(specular, 1);
21 }
来思考一下能量:
光的总能量=漫反射能量 + 高光能量 + 物体自己吸收的能量
上面的算法里,如果高光颜色为(1,1,1)且 smooth 系数为较小值的时候,就会出现漫反射能量 + 高光能量 > 光的总能量的情况,我们并没有考虑能量的归一化
我们倒不用考虑太细腻,只需要保证:高光变强的时候,对应的漫反射要变弱,反之亦然
最简单粗暴的方法是:将漫反射颜色乘以 1-_SpecularTint
1light += diffuse*albedo*(1 - _SpecularTint.rgb);
当然,我们这样会有极端情况:当光照为纯绿色的时候,_SpecularTint 为纯红色,我们还是会有能量加起来大于 1 的情况
因此我们需要取最大值,而不是直接减
1light += diffuse*albedo*(1 - max(_SpecularTint.r, max(_SpecularTint.g, _SpecularTint.b)));
我们目前使用的是 Specular工作流.即用 SpecularTint 来反映物体的镜面反射强度和漫反射材质
虽然易于控制高光颜色,但是调节起来不太好的点是,金属和非金属之间的转换有点麻烦,得依赖调颜色
我们引入一个新的工作流金属工作流。我们用一个值来控制金属和非金属之间的插值。
我们引入
1_Metallic ("Metallic", Range(0, 1)) = 0
2
3float _Metallic;
然后对应计算
1float3 _SpecularTint = albedo * _Metallic;
2...
3float3 diffuse = lightColor * DotClamped(i.normal, lightDir);
4float3 spec = lightColor * pow(DotClamped(r, viewDir), _Gloss * 100.0) * _SpecularTint;
5light += diffuse*albedo*(1.0-_SpecularTint);
6light += spec;
这样可以更加方便地在金属和非金属之间通过一个滑块来做切换了
不确保正确
原文因为是 Unity5,用的还是 Gamma 空间。因此提到了,其实金属度的插值并非线性插值,因为颜色空间的差异。
我不确定 Urp 是否有这个问题,毕竟 Urp 基本上都是线性空间项目,理论来说,应该是没问题的
好的,到现在基础的光照模型就学到这里吧,是时候用无敌的 PBR 来代替我们这个简陋的光照模型了!
这里直接上 Fragment 的代码,因为基本只要改 fragment 就可以了
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).rgb;
7
8 // 设置表面属性
9 BRDFData brdfData;
10 // 初始化BRDF数据
11 half oneMinusReflectivity = 1 - _Metallic;
12 half3 specColor = lerp(kDieletricSpec, albedo, _Metallic);
13 InitializeBRDFData(albedo, _Metallic, specColor, _Gloss, oneMinusReflectivity, brdfData);
14
15 // 获取主光源信息
16 Light mainLight = GetMainLight();
17
18 // 计算视线方向
19 float3 viewDir = normalize(GetCameraPositionWS() - i.worldPos);
20
21 // 计算直接光照
22 half3 color = LightingPhysicallyBased(brdfData, mainLight, i.normal, viewDir);
23
24 return half4(color, 1.0);
25}
其中要解释一下的是 kDieletricSpec 这个颜色,它是一个常量
1const float3 kDieletricSpec = float3(0.04, 0.04, 0.04);
它代表了普通非金属物质的高光反射率。算是一个常用的极小值吧。
其他要说的就几乎没有了,可能也就是一个 oneMinusReflectivity,其实就是 1-Metallic,没什么特别的。
关于 BRDF 的详细介绍,可以参考我之前的文章
然后我们就,大功告成!
这篇文章主要介绍了从单灯光进行光照的一些内容。
说实话,我不太想从矩阵空间变换,贴图映射之类的东西从头讲起来。我觉得从这篇开始讲刚刚好。
原作毕竟是 Unity5 的产物,还是古老的 builtin 管线,因此实际上非常多的内容都是对不上的。我之前也犹豫过,如此久远的内容真的有学习的必要吗?
但是这篇写完以后,我的内心是很确定的,很有必要。非常感谢 cat-likeCoding 的作者,他的教学知识密度非常高,但是又尽可能地用深入浅出的方式讲解。
另外就是,纸上得来终觉浅,绝知此事要躬行。很多细节的内容要自己做一遍,才能有更深的理解,比如法线的插值问题,比如法线的变换问题。
这篇基本算是我日常肝独游以外的时间赶出来的,基本上现在的写作节奏都是半夜 23 点工作干完了,吃个麦当劳,然后快乐打开 cat-likeCoding 开肝,肝到早上 6,7 点,再睡到下午。
虽然辛苦,但是真的久违了慢慢学东西的快乐。可能现在这种 AI 时代,我们都会被飞速成长的 AI 代替。但是这种单纯一步一步掌握知识,然后记录的快乐我觉得是没法替代的。甚至我现在对这件事有点上瘾了。