MEGALOVANIA MEGALOVANIA

Audaces fortuna iuvat:命运眷顾勇敢之人

目录
图形学 渲染篇 其一:第一道光
/  

图形学 渲染篇 其一:第一道光

别问我为什么名字这么中二,cat-likeCoding 这么叫的

本文主要参考 cat-likeCoding 的 Rendering 的第四章

前置知识

首先,我不会讲所有的内容,或者至少不在这篇里讲,所以需要有一些前置知识:

  • 基础的图形学矩阵推导(推荐看 Unity 入门精要的数学部分)
  • vert 和 frag 分别是干嘛的
  • 基础的贴图映射,uv 坐标等

法线变换

模型空间到世界空间

首先要计算光照,就必须得有法线,首先在 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 这三种矩阵的依次相乘,把这个整体的变换矩阵写出来就是:

O=S_1R_1P_1S_2R_2P_2

这是点,如果是向量,我们不需要考虑位移,那么应该是这样

O=S_1R_1S_2R_2

刚才我们的操作里,正是因为这个缩放矩阵 S 的存在,才导致我们的法线变形。我们只想要旋转,想要消除缩放的影响,因此我们期待的法线的变换矩阵应该是这样

N=S_1^{-1}R_1S_2^{-1}R_2

接下来就是神奇的线性代数魅力时刻,我不会给出推导。

但是结论来说,对于旋转 R,位移 P,缩放 S 三种矩阵来说,我们都有办法来简单求到他的逆矩阵:

对于位移 P,直接将位移的量变成负数即可,简单易懂

对于缩放 S,它其实是一个对角矩阵,变换起来也非常简单

对于旋转 R,它的逆矩阵等于自己的转置矩阵

同时,各大引擎基本也会提供世界空间到模型空间的变换矩阵,其实就是前面变换

O

的逆矩阵,我们可以写作:

O^{-1}=R_2^{-1}S_2^{-1}R_1^{-1}S_1^{-1}

然后根据线性代数知识,我们幸运得到了下面的东西:

(O^{-1})^{T}=(S_1^{-1})^T(R_1^{-1})^T(S_2^{-1})^T(R_1^{-1})^T=S_1^{-1}R_1S_2^{-1}R_2=N

为什么会有这样的结论?我们观察下旋转矩阵 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 模型

鼎鼎大名的光照模型,主要的区别在于:用半角向量和法线的点积代替反射光和视角的点积

半角向量其实就是观察向量和光照方向的平均值

大概的图示

其中

H = \frac{V+L}{|V+L|}

明显 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

好的,到现在基础的光照模型就学到这里吧,是时候用无敌的 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 代替。但是这种单纯一步一步掌握知识,然后记录的快乐我觉得是没法替代的。甚至我现在对这件事有点上瘾了。


标题: 图形学 渲染篇 其一:第一道光
作者:matengli110
地址:https://www.sunsgo.world/articles/2025/04/11/1744327006106.html