作者:王子饼干 ——镇江虹视游戏科技有限公司联合创始人、《多洛可小镇》制作人
原文链接:UnityShader入门精要-URP管线2. 基础光照
原文发布时间:2022年6月22日
文章类型:授权转载
笔记当前的Unity版本:2019.3
当前最新的Unity版本:2020.2
又回到了这个经久不衰的话题,即光照,其实准确点来说,光照是每个游戏渲染技术的核心。而我们近几年的升级,大部分也都是为了光照。从基础的Lambert和Blinn-Phong光照模型到复杂的光追算法,毕竟,所谓“看起来真实”这件事,来源于光照,而非色彩。
在本章节中,我们会先简单回顾一下兰伯特漫反射光照模型,然后利用HLSL来翻译Built-In管线中的代码。
从一般性的角度来说,构建着色器大体分为两个部分,第一是原理,第二是具体的实现方式。实现方式是多种多样的,你可以用OpenGL+GLSL实现,也可以用DirectX+HLSL实现。但是无论怎样,原理都是一样的,这也是为什么算法才是核心。可以深挖的,而语法至多做到熟练就可以了。
兰伯特定律是一种描述CG当中的经验性漫反射定律,它是这样说的。
漫反射光的强度近似地服从于Lambert定律,即漫反射光的光强仅与入射光的方向和反射点处表面法向夹角的余弦成正比。
这段话中可以拆分为几个点来看。
第一,近似的服从,也就是并不完全服从,主要说明它是一个经验模型。而非基于物理的渲染(PBR)
第二,入射光的方向,即我们需要入射光的角度,(当然还需要强度)
第三,反射点出的表面法线,我们需要顶点的法线
第四,与余弦成正比,我们需要计算入射光与顶点法线的余弦值,以计算光在该点的强度。
根据上面四个点,我们来简单梳理一下这个公式。首先,入射光的方向与反射点处的表面法线夹角的余弦成正比。我们就需要计算两个方向的余弦值,且不说这两个方向是什么,我们先看一下怎么计算两个方向的余弦值。
假设我们有A和B两个向量,我们可以得到以下关系。其中θ为A和B的夹角。
这样,我们计算出了A向量和B向量的夹角值。
又由于,在夹角超过90°的地方,余弦值会变成负数,而这显然不符合光强度大于等于0的现实问题,所以我们给它加上一个限度。因而漫反射可以表示为下面这样。
其中L为光照方向,而N为法线方向,I为光照强度。虽然这个公式已经十分简单了,但是我仍然推荐新手或者刚入门的人多理一下。
我们之前使用CG实现过,现在无非是更换了不同的API,对于老手来说,这可能是查个API的事情,但是对于新手来说可能就是自废武功了。所以我们慢慢的来过,不要急躁。
既然更换了API,那么库肯定也更换了,所以我们主要了解几个核心的HLSL库。
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl"
这个任务主要了解三个库,Core.HLSL,Lighting.HLSL以及SpaceTransforms.hlsl。其中Core.HLSL是其他库的集合,相当于在Core当中引用了别的HLSL,不过并不是所有的都引用了,比如Lighting.HLSL你还是要显式的引用一次。
不过SpaceTransforms.HLSL已经被Core.HLSL引用了,所以当你引用Core.HLSL的时候,就可以直接使用SpaceTransforms.HLSL中的函数了,但是我们仍然显式的引用了一次,主要是为了vscode可以获取这个库的代码提示,帮助我们编写代码。
我没有把所有的库函数都抛出来,毕竟有一些后面才会用得到。所以我们每个部分都只展示该节所用的API。
我们使用到了以上的一些API,都不是非常的难理解,差不多就相当于换了一种说法。那么稍微注意一下GetMainLight这个函数。它会返回一个光源信息,这个信息是一个结构体。我们大概的看一下。
struct Light
{
half3 direction;
half3 color;
half distanceAttenuation;
half shadowAttenuation;
};
上述结构体当中,direction和color都是非常显而易见的,而下面两个,一个是距离衰减,一个是阴影衰减,对于模拟太阳的方向光来说是用不到的。所以我们暂时不管,只需要知道direction代表光的方向,而color代表颜色&强度即可。
然后再研究一下GetMainLight的源代码,主要是了解它如何来配置Light结构体。
Light GetMainLight()
{
Light light;
light.direction = _MainLightPosition.xyz;
// unity_LightData.z is 1 when not culled by the culling mask, otherwise 0.
light.distanceAttenuation = unity_LightData.z;
#if defined(LIGHTMAP_ON) || defined(_MIXED_LIGHTING_SUBTRACTIVE)
// unity_ProbesOcclusion.x is the mixed light probe occlusion data
light.distanceAttenuation *= unity_ProbesOcclusion.x;
#endif
light.shadowAttenuation = 1.0;
light.color = _MainLightColor.rgb;
return light;
}
emmm,看到了一堆奇奇怪怪的内容,不过不用着急去研究,因为我们暂时只需要了解direction和color,从上述代码来看,它们一个是从_MainLightPosition中获取,一个是从_MainLightColor中获取,还记得吗?在CG当中,我们通过_LightColor0来获取这些光源信息。放一下UnityShader入门精要学习笔记5中的截图。
我猜你已经发现了,之前CG中用于表示方向的fixed类型变成了half类型,这个纯粹是HLSL和CG的区别,我们可以从微软的官方文档中了解关于HLSL的数据类型。
在Unity的HLSL中,我们一般会使用half和float,fixed则只在部分显卡中支持。关于该部分,我们可以从Unity官方文档中得到一些指导。另外,half类型在PC平台和手机平台的有一定的区别。在实际的开发当中,还是要多进行测试。
着色器数据类型和精度 - Unity 手册docs.unity3d.com/cn/current/Manual/SL-DataTypesAndPrecision.html
以下是所有关于该节的代码。
Shader "URPNotes/Lambert"
{
Properties
{
//着色器的输入,注意Unity所规定的类型不变
_BaseColor ("Color", Color) = (1,1,1,1)
}
SubShader
{
Tags {"RenderType"="Opaque" "RenderPipeline" = "UniversalRenderPipeline"}
//设定为URP
pass{
HLSLPROGRAM
/* 主要的着色器内容 */
#pragma vertex Vertex
#pragma fragment Pixel
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl"
//引用
CBUFFER_START(UnityPerMaterial)
/*输入变量的声明要包在CBUFFER_START(UnityPerMaterial)和CBUFFER_END中*/
half4 _BaseColor;
CBUFFER_END
struct VertexInput{
//顶点着色器的输入,我们需要顶点位置和法线,语义和CG中一样
float4 vertex:POSITION;
half3 normal:NORMAL;
};
struct VertexOutput{
//顶点着色器的输出
float4 pos:SV_POSITION;
half3 worldNormal:TEXCOORD0;
};
VertexOutput Vertex(VertexInput v){
/* 顶点着色器 */
VertexOutput o;
o.pos = TransformObjectToHClip(v.vertex.xyz); //将顶点转换到裁剪空间,这步是顶点着色器必做的事情,否则
//渲染后模型会错位。
o.worldNormal = TransformObjectToWorldNormal(v.normal); //将法线切换到世界空间下,注意normal是方向
return o;
}
half4 Pixel(VertexOutput i):SV_TARGET{
/* 片元着色器 */
Light mlight = GetMainLight(); //获取主光源的数据
float power = saturate(dot(mlight.direction,i.worldNormal)); //计算漫反射强度
return _BaseColor * power * half4(mlight.color,1); //将表面颜色,漫反射强度和光源强度混合。
}
ENDHLSL
}
}
}
Ok,上面就是所有的代码了,稍微解析一部分。
CBUFFER_START(UnityPerMaterial)
/*输入变量的声明要包在CBUFFER_START(UnityPerMaterial)和CBUFFER_END中*/
half4 _BaseColor;
CBUFFER_END
着色器的面板输入的变量声明被包裹在了CBUFFER_START(UnityPerMaterial)和CBUFFER中,我们可以在UnityURP教程中找到该部分的解释。
原文
NOTE: To ensure that the Unity shader is SRP Batcher compatible, declare all Material properties inside a single
CBUFFER
block with the nameUnityPerMaterial
. For more information on the SRP Batcher, see the page
译文(个人渣翻)
注意:为了确保UnityShader可以兼容SRP批处理,你需要把所有的材质属性声明在一个名为UnityPerMaterial的CBUFFER块中,如果你希望了解更多信息,请查看以下页面。
Scriptable Render Pipeline (SRP) Batcherdocs.unity3d.com/Manual/SRPBatcher.html
emmm,从中我们了解的信息不多,不过可以肯定的是,这个是类似于语法一样的非重点。如果后面遇到了,我们再来研究。
语义在HLSL与CG中的概念是一样的(因为这里实在是没有必要去做两套不同的东西),但是我们仍然要强调语义在不同的环境下含义是不同的。(特指TEXCOORD)
struct VertexInput{
//顶点着色器的输入,我们需要顶点位置和法线,语义和CG中一样
float4 vertex:POSITION;
half3 normal:NORMAL;
};
struct VertexOutput{
//顶点着色器的输出
float4 pos:SV_POSITION;
half3 worldNormal:TEXCOORD0;
};
VertexOutput Vertex(VertexInput v){
/* 顶点着色器 */
VertexOutput o;
o.pos = TransformObjectToHClip(v.vertex.xyz); //将顶点转换到裁剪空间,这步是顶点着色器必做的事情,否则
//渲染后模型会错位。
o.worldNormal = TransformObjectToWorldNormal(v.normal); //将法线切换到世界空间下,注意normal是方向
return o;
}
在顶点着色器中,我们使用了TransformObjectToWorldNormal来将法线转换到世界空间下,本来没有什么特殊的,但是SpaceTransforms.hlsl中还有一个TransformObjectToWorldDir函数,讲道理前者应该是后者的特殊情况,不过我两个都试了一下,在兰伯特漫反射模型中,它俩的表现是一样的,所以我决定研究一下TransformObjectToWorldNormal的源代码。直接copy过来给大家看一下。
float3 TransformObjectToWorldDir(float3 dirOS, bool doNormalize = true)
{
float3 dirWS = mul((float3x3)GetObjectToWorldMatrix(), dirOS);
if (doNormalize)
return SafeNormalize(dirWS);
return dirWS;
}
float3 TransformObjectToWorldNormal(float3 normalOS, bool doNormalize = true)
{
#ifdef UNITY_ASSUME_UNIFORM_SCALING
return TransformObjectToWorldDir(normalOS, doNormalize);
#else
// Normal need to be multiply by inverse transpose
float3 normalWS = mul(normalOS, (float3x3)GetWorldToObjectMatrix());
if (doNormalize)
return SafeNormalize(normalWS);
return normalWS;
#endif
}
首先解释一下多出来的一个参数doNormalize,也就是是否在计算完成之后正则化。TransformObjectToWorld非常简单,也就是用模型转世界空间矩阵对法线进行转换。没有特别要说明的,我们着重看一下TransformObjectToWorldNormal,这个函数使用到了一个特别的宏,UNITY_ASSUME_UNIFORM_SCALING。
从字面意思来看,这个宏表示的意思是,假设是统一变换。在URP的文档中并没有找到这个宏,UnityManual文档中也没有找到,不过倒是有一个编译指令和它很相似。
这里的意思是说,用于指示Unity,对于所有xyz轴的都是统一的缩放。
但是抛开这个宏不谈,我们其实可以直接观察代码的区别,当定义了UNITY_ASSUME_UNIFORM_SCALING,按照普通的TransformObjectToWorldDir函数来进行变换。否则的话,则手动计算。与TransformObjectToWorldDir的唯一区别是,它是右乘。
不过我暂时没有能参透其中的原理,如果有大神知道,请不吝赐教。
最终放一下漫反射的结果图。大概是下面这样的。
ok,那么本篇到这里就结束了,对于HLSL来说,我们主要是了解语法层面的,所以着重于了解关于源代码或者一些特殊的宏或者编译指令等。本次只有漫反射一个代码。可以在下面的链接中找到,喜欢这个系列可以点个关注,或者给我的github贡献一颗⭐