点击阅读原文,可以访问陈景光在 Unity 中文课堂的个人主页,阅读更多技术干货。
课程案例工程文件
课程案例工程文件 GitHub 地址:
工程文件包含 Shader 源代码和 ShaderGraph 版本 Shader,对代码不熟悉的美术同学也可以通过 ShaderGraph 学习其原理。
光线分析
渲染方程
1. 为什么要了解渲染方程?
光线射向渲染点然后出射到观测者眼睛
我们可以得出进入观测者眼睛的 Radiance 公式:
N·ωi 是两个矢量的点乘,意思是入射方向在法线方向上的投影,Li(p,ωi) 是入射光线的能量(指向 p 点方向是 ωi 的入射光线)。直觉上告诉我们,当光线直射与斜射入表面,能量会不一样,例如太阳光入射地球不同纬度的表面,气温都会有非常明显的变化。
取自Games 101 闫令琦
而 N·ωi 两个矢量的点乘,就能反映入射角度与到达表面能量的变化,N·ωi 实质上是指 ωi 在 N 方向上的投影,显然当 ωi 直射表面的时候,投影长度最大;当 ωi 与 N 方向垂直时,投影长度为 0。
综上所述,Li(p,ωi)(N·ωi) 这物理量就能反映光源到达渲染点 p 的能量。前面再与一个函数 fr(p,ωi,ωr) 相乘,就能得到 Lo(p,ωo) 的值,而 fr 通常是指 BRDF(双向反射分布函数)。根据能量守恒法则,我们看到的 Lo(p,ωo),最大值是 Li(p,ωi)(N·ωi),这是镜面反射状态并且光线完全没有被吸收和折射的情况,因此 BRDF 的值会在 [0,1] 之间。根据不同的物质属性,光线到达表面会有不同情况的反射、折射、散射和吸收,反射出来的光线会有不同程度的损失,而 BRDF 通常就是我们通俗上讲的材质。
BRDF
现实环境不可能只有一个点光源,我们现在来研究多个光源情况下的Lo(p,ωo)值。
多光线入射到渲染点然后出射到观测者方向
此时 Lo(p,ωo) 就应该把所有方向的入射光线贡献加起上来,公司如下面所示:
3. 渲染方程
现实环境的光源可以认为是表面法线半球方向上有无限个连续的点光源(物体之间相互反弹的间接光也能传递能量,因此间接光源也认为是有贡献的光源),计算连续的量就不能简单用求和,需要用到积分。此时我们要计算 p 点进入观测者眼睛的 Radiance 就是所有半球方向 ωi 的定积分,再加上物体本来辐射出去的光线,就是渲染方程。
Lo(p,ωo) 是物体本身发出的光线,后面再加上所有半球方向的入射方向 ωi 的积分,就是渲染方程,而渲染基本上都是围绕这个方程去进行研究。
冰材质的光线分析
1. 线可逆性质:
2. 冰材质的光线分析:
冰块中的散射现象
冰块的光线模型分析,假设冰块中的杂质是均匀分布的
假设光线从观测者射出,入射光线 ωi 的能量会分散到反射方向(ωrefle)、折射方向(ωrefra)、散射方向(ωd),因此思路是我们独立去计算反射、折射和散射方向的能量,再把它们加起来就行。
从实验可以看出入射光线的能量是反射光线与折射光线的能量加起上来
顺便提及一个知识点,上面所讲述的 BRDF 问题,其实只是单纯研究反射的问题,如果研究折射问题,就必须加上 BTDF(双向透射分布函数),BRDF 和 BTDF 加起来统称为 BSDF。
图中光线箭头的粗细就能反映光线能量的分布情况
3. 冰材质的反射与折射模拟思路:
反射与折射是最好模拟了,我们就通过一张 Cube Map 进行反射方向与折射方向的采样就行,效率也非常高,详细的方法将会在后续讲到。
4. 冰材质的散射模拟思路:
至于冰块里面的散射模拟,就没有那么简单,首先它涉及物体体积的问题,并不能简单通过表面计算。体积问题我们这里用物体厚度的思路来模拟,薄的地方散射能量弱,厚的地方散射能量强,而散射能量用环境光的平均颜色来模拟。
5. 最终结果:
最后我们把计算得出来的反射、折射、散射全部加起上来,就是我们想要的结果了。
建立 Shader 基本框架
2. Shader 文件
Shader "Unlit/Ice"
{
Properties
{
}
SubShader
{
Tags {"RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" "Queue"="Geometry"}
Pass
{
Name "Unlit"
HLSLPROGRAM
// -------------------------------------
ENDHLSL
}
}
}
CBUFFER_START(UnityPerMaterial)
CBUFFER_END
struct Attributes
{
float4 positionOS : POSITION;
};
struct Varyings
{
float4 positionCS : SV_POSITION;
};
Varyings UnlitPassVertex(Attributes input)
{
Varyings output = (Varyings)0;
VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
vertexInput.positionCS; =
return output;
}
half4 UnlitPassFragment(Varyings input) : SV_Target
{
return half4(1,1,1,1);
}
构建 Shader 思路
1. 了解渲染流水线
对渲染流水线有个全局的了解能增加我们的全局思维,对每个细节的设计都能够清晰地定位是整个流程的哪个部分,帮助我们理解。
首先了解一下渲染流水线的大致过程:
渲染流水线 参考 Games 101 闫令琦
最初从应用程序里面,获取一系列的数据(物体空间的顶点数据、材质数据、变换矩阵、灯光数据等等);在顶点着色器,每个顶点数据通过逐顶点计算,得出经过变换的屏幕空间的顶点数据,此时的顶点数据依然是 3D 的,只不过变换了坐标系而已;接着在几何着色器组成三角形数据;紧接着就是光栅化阶段,此阶段把 3D 数据转换成 2D 数据,光栅化一般直接由硬件完成,不需要对其进行编程处理,之前的顶点数据通过三角形的重心坐标插值成片元数据,交给片元着色器进行逐像素计算;在片元着色器,计算是逐像素处理,一般纹理采样、颜色混合、深度剔除都在这里进行处理,片元着色器输出的是各种帧缓冲,一般不进行后处理的话会直接送给屏幕缓冲进行显示;最后阶段就是后处理,在这阶段是对各种缓冲区的数据进行计算并最终输出到屏幕缓冲。以上是大致流程,具体可以参考 Games 101 或者相关更详细的文献。
编写 Shader 实际上就是在渲染流水线中完成对顶点着色器和片元着色器的编程,从上述的基本 Shader 结构可以得知,顶点着色器对应函数 UnlitPassVertex, 片元着色器对应函数 UnlitPassFragment,接下来我们了解下这两个函数的输入和输出。
2. 建立数据结构体
在 Shader 中,有大量的 Position、Direction、纹理坐标等数据,相同的矢量数据还有不同坐标空间的区别,这使得编写函数参数的时候非常不方便,每次都要输入一大串参数和输出一大串参数。因此,建立数据结构体来管理输入输出参数非常有必要。
在 URP 的 Unlit Shader 中看到,顶点着色器的输入使用了结构体 Attributes,而顶点着色器的输出使用了结构体 Varyings。而在片元着色器,往往输入数据不能直接从 Varyings 得到,这是由于 Varyings 数据都是顶点数据通过三角形重心坐标插值而来,准确性存在比较大的偏差。
三角形中的任意一点的插值数值是三个顶点的数值通过重心坐标加权平均获得。
例如模型的表面法线信息,如果直接取 Varyings 的法线参数来计算高光,结果会非常不自然,这是由于插值后的法线信息,不再是归一化的方向信息,我们需要在片元着色器逐个像素归一化计算才能重新获得正确的法线信息。
直接从 Varyings 获取法线计算的高光和逐像素归一化法线后计算的高光
这就需要我们建立额外的结构体来管理片元函数的输入参数,在 URP 的 Lit Shader 是使用 IntputData 这个结构体,片元着色器需要的输入参数,我们都装在这个结构体里面。
建立一个新的包含文件 IceFn.hlsl,我们首先建立一个 InputData 结构体,在这里我们把这个 InputData 结构体命名为 IceInputData,避免和 URP 的 InputData 冲突。
3. IceFn 包含文件
struct IceInputData
{
};
void InitializeIceData(Varyings input, out IceInputData inputData)
{
inputData = (IceInputData)0;
}
在定义结构体的后面接着定义一个初始化函数,用来初始化 IceInputData。
记得要在 ForwardPass 包含这个文件:
struct Attributes
{
float4 positionOS : POSITION;
};
struct Varyings
{
float4 positionCS : SV_POSITION;
};
模型从Local Space到Screen Space的变换
可以看到各种坐标空间的变换和 Matrix 矩阵有关,下面我们来看看 Unity 内部提供给我们有哪些矩阵。
Unity 提供的内置矩阵是 4x4 的矩阵,与矢量左乘(矩阵放左边)就能得到变换新坐标后的矢量数据。这里提一下矢量必须写成四维而不是三维,位置写成(x, y, z, 1),方向写成(x, y, z, 0),具体原理可以参考 Games 101 的齐次坐标矩阵变换。
例如模型空间的顶点位置变换到世界空间的顶点位置:
float3 positionWS = mul(UNITY_MATRIX_M, float4(positionOS, 1)).xyz;
例如模型空间的法线方向变换到相机空间的法线方向:
float3 normalVS = mul(UNITY_MATRIX_IT_MV, float4(normalOS, 0)).xyz;
//或者更简单的写法
float3 normalVS = mul((float3x3)UNITY_MATRIX_IT_MV, normalOS);
struct IceInputData
{
// 当前相机的世界空间位置
float3 cameraPosition;
// 模型表面着色点的世界空间位置
float3 positionWS;
// 世界空间的视觉方向,模型着色点指向相机的方向
float3 viewDirectionWS;
// 切线空间的视觉方向,是viewDirectionWS的不同空间描述
float3 viewDirectionTS;
// 世界空间的顶点法线方向
float3 vertexNormalWS;
// 世界空间的像素法线方向(包含法线贴图信息)
float3 pixelNormalWS;
// 切线空间的法线方向
float3 normalTS;
// 系统的运行时间
float time;
};
half3 BlendAngleCorrectedNormals(half3 baseNormal, half3 additionalNormal)
{
+= 1.0;
*= -1.0;
half d = dot(baseNormal, additionalNormal);
half3 tempBase = baseNormal * d;
half3 tempAdd = additionalNormal * baseNormal.b;
return tempBase - tempAdd;
}
void EffectNormal(float3x3 t2w, float2 uv, out half3 normalTS, out half3 normalWS)
{
normalTS = half3(0, 0, 1);
#ifdef _USEGLASSNORMAL
normalTS = UnpackNormal(tex2D(_GlassNormal, uv));
*= _GlassNormalScale;
normalTS = normalize(normalTS);
#ifdef _USEDETAILNORMAL
half3 detailNormalTS = UnpackNormal(tex2D(_DetailNormal, uv * _GlassDetailUVScale));
normalTS = BlendAngleCorrectedNormals(normalTS, detailNormalTS);
normalTS = normalize(normalTS);
#endif // _USEDETAILNORMAL
#endif // _USEGLASSNORMAL
normalWS = TransformTangentToWorld(normalTS, t2w);
}
void InitializeIceData(Varyings input, out IceInputData inputData)
{
inputData = (IceInputData)0;
切线空间到世界空间的转换矩阵,由于每个顶点的切线坐标不一样,因此无法通过Unity统一传递过来,需要每个顶点去计算
float3x3 t2w = float3x3(input.tangentWS.xyz, input.bitangentWS.xyz, input.normalWS.xyz);
GetCameraPositionWS(); =
input.positionWS; =
在顶点着色器已经计算出来并储存在相关通道上
normalize(float3(input.normalWS.w, input.tangentWS.w, input.bitangentWS.w)); =
TransformWorldToTangent(inputData.viewDirectionWS, t2w); =
normalize(input.normalWS.xyz); =
input.uv0.zw, inputData.normalTS, inputData.pixelNormalWS);
_Time是Unity传递过来的时间参数,x:t/20 y:t z:t*2 w:t*3
_Time.x; =
}
详细可以参考源代码。
阔别重逢,Unite 全球开发者大会终于回来了!
7月23日-25日,Unite 将于上海隆重开启。1 场 Keynote 和多个专场,涵盖团结引擎、游戏生态、数字孪生、智能座舱等多重赛道,我们还准备了小而美的工作坊,覆盖小游戏、开源鸿蒙、Vision Pro 三大主题。
走过路过别错过。
点击“阅读原文”,学习完整课程