PBR直接光部分 理论+U3D实现

本文主要对PBR直接光部分做一个概述以及实现。

要求:
1.需要熟悉简单的积分运算。
2.需要熟悉Shader的Coding。
3.需要一定的耐心。

Physically Based Rendering(理论部分)

能量守恒

PBR,顾名思义,是一个基于物理的渲染方式。PBR的应用在游戏产业里非常常见,因为PBR有着与其它渲染方式的不同之处,其中之一就是能量守恒。

在这里插入图片描述

能量守恒

PBR的入射光线能量不会超过反射光线的能量(不讨论发光表面)。
与之相对的就是Blinn-Phong这种基础光照渲染。Blinn-Phong的高光计算是独立的,这就会导致无论高光参数设置成多少都不会影响漫反射光的计算,而这是违背自然界的规律的。

Gloss=8
在这里插入图片描述
Gloss = 256
在这里插入图片描述

微平面模型

在这里插入图片描述
微平面模型简而言之就是平面越粗糙,微平面的分布越无序,反射光线的光线越混乱。

但是做出这样复杂的模型显然是不可能的,所以我们只能使用PBR给出的统计学的模型来模拟凹凸不平的表面,因为很多材质的表面分布是连续且一致的。

辐射度量学

首先,读者需要稍微了解关于辐射度量学的内容(Radiometry)。

在辐射度量学中,最基本的单位是辐射通量(Radiant flux) Φ \Phi Φ。辐射通量是在单位时间内光源所输出的能量,单位是瓦特(W)。

辐射照度或者辐照度(Irradiance)是辐射通量的密度与单位面积的比值: d Φ / d A d\Phi/dA dΦ/dA。单位为瓦特每平方米。

立体角:可以理解为三维空间里的角度。

在这里插入图片描述

要理解立体角,首先要熟悉圆心角的计算: d θ = d s r d\theta=\frac{ds}{r} dθ=rds。其中,s是圆弧的长度。如果我们对式子左右两边分别做0到2 π \pi π的积分的话,显而易见是成立的。

立体角的计算也变差不大: d ω = d A r 2 d\omega=\frac{dA}{r^2} dω=r2dA
如果我们对右边的式子做球面积分的话(球体表面积为 4 π r 2 4\pi r^2 4πr2),我们会得到 ω = 4 π \omega=4\pi ω=4π。所以立体角的宏观定义是 ω = A r 2 s r \omega=\frac{A}{r^2}sr ω=r2Asr。sr为立体角的单位:Steradian。

辐射强度(Radiant intensity):表示的是在单位球面上,一个光源向每单位立体角所投送的辐射通量。用 I = d Φ d ω I=\frac{d\Phi}{d\omega} I=dωdΦ表示

所以说我们可以得到辐射率的方程:
L = d 2 Φ / d A d ω L=d^2\Phi/dAd\omega L=d2Φ/dAdω
. 注意,这里的A是垂直于入射光线的面积。

反射率方程

接下来就到了我们的重头戏了:反射率方程
在这里插入图片描述
乍一看非常的吓人,但是稍稍分析一下也不过如此。

这是一个关于各项同性(isotropic)光线渲染的公式。

各项同性:简而言之就是:给定光源和摄像机的位置,无论这个物体怎么旋转,它的效果都不会变。
在这里插入图片描述
反射率方程是基于半球积分计算出来的结果。这个半球是基于p点法线向量而确定的。此积分是用来检测该点所处的单位半球上所有射入该点入射光线的方向。如果场景里只有一个直接光,这个半球积分就可以去掉。

如果场景中有很多个光源,我们需要运用球面坐标来检测.
借用一下RTR 4th的图。
在这里插入图片描述
在这里插入图片描述

L i ( p , l ) L_i(p,l) Li(p,l)是光源的颜色,在我们这个项目中就是直接光的颜色。

BRDF(双向反射分布函数)

BRDF,全称是Bidirectional reflectance distribution function,代表的是反射率方程中的 f ( l , v ) f(l,v) f(l,v).

BRDF笔者就不多解释了,贴一个讲的好的:
https://learnopengl-cn.github.io/07%20PBR/01%20Theory/

总而言之,BRDF由这两项组成

f ( l , v ) = k d f l a m b e r t + k s f c o o k − t o r r a n c e f(l,v)=k_df_{lambert}+k_sf_{cook−torrance} f(l,v)=kdflambert+ksfcooktorrance

由公式可知,BRDF是由Lambertian 漫反射和 Cook-torrance高光反射的加权平均得来的。

PBR直接光(实现)

这里借鉴了 https://zhuanlan.zhihu.com/p/68025039 大佬的代码框架。

Shader "TechArt/PBR"
{
	Properties
	{
		_MainTex("Texture", 2D) = "white" {}
		_Tint("Tint", Color) = (1 ,1 ,1 ,1)
		[Gamma] _Metallic("Metallic", Range(0, 1)) = 0 //金属度要经过伽马校正
		_Smoothness("Smoothness", Range(0, 1)) = 0.5
		_LUT("LUT", 2D) = "white" {}
	}

		SubShader
	{
		Tags { "RenderType" = "Opaque" }
		LOD 100

		Pass
		{
			Tags {
				"LightMode" = "ForwardBase"
			}
			CGPROGRAM


			#pragma target 3.0

			#pragma vertex vert
			#pragma fragment frag

			#include "UnityStandardBRDF.cginc" 
		    #include "UnityStandardUtils.cginc"
			
			#define pi 3.1415927

			struct appdata
			{
				float4 vertex : POSITION;
				float3 normal : NORMAL;
				float2 uv : TEXCOORD0;
			};

			struct v2f
			{
				float4 vertex : SV_POSITION;
				float2 uv : TEXCOORD0;
				float3 normal : TEXCOORD1;
				float3 worldPos : TEXCOORD2;
			};

			float4 _Tint;
			float _Metallic;
			float _Smoothness;
			sampler2D _MainTex;
			float4 _MainTex_ST;
			sampler2D _LUT;

			v2f vert(appdata v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.worldPos = mul(unity_ObjectToWorld, v.vertex);
				o.uv = TRANSFORM_TEX(v.uv, _MainTex);
				o.normal = UnityObjectToWorldNormal(v.normal);
				o.normal = normalize(o.normal);
				return o;
			}

			fixed4 frag(v2f i) : SV_Target
			{
                i.normal = normalize(i.normal);
                float3 lightDir = normalize(_WorldSpaceLightPos0.xyz);
                float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
                float3 lightColor = _LightColor0.rgb;
                float3 halfVector = normalize(lightDir + viewDir); 

                float perceptualRoughness = 1 - _Smoothness;

                float roughness = perceptualRoughness * perceptualRoughness;
                float squareRoughness = roughness * roughness;
                
                float3 diffColor = 0;
                float3 specColor = 0;
                float3 DirectLightResult = (diffColor + specColor) * lightColor* nl;

                float3 iblDiffuseResult = 0;
                float3 iblSpecularResult = 0;
                float3 IndirectResult = iblDiffuseResult + iblSpecularResult;

                float4 result = float4(DirectLightResult + IndirectResult, 1);

                return result;
            }

			ENDCG
		}
	}
}

注:代码里的h代表了半角向量

半角向量(half vector):在这里插入图片描述
半角向量是由视角向量和光源向量的和所计算出来的,并且需要标准化,用公式表示就是:
h = l + v ∣ ∣ l + v ∣ ∣ h=\frac{l+v}{\mid\mid l+v\mid\mid} h=l+vl+v
半角向量在计算过程中会经常出现。

直接光漫反射

f l a m b e r t = c π f_{lambert}=\frac{c}{\pi} flambert=πc
c表示的是表面颜色,在shader代码里是就是

                //漫反射
                fixed4 albedo = _Tint * tex2D(_MainTex, i.uv);
                float3 diffColor = color;

注:这里没有除以pi是因为Unity声明除以pi会让着色器变得更加暗,而且在之后的ibl部分也会对此做出相应的处理。

效果:
在这里插入图片描述
经典的兰伯特漫反射模型。

直接光高光反射

f c o o k − t o r r a n c e = D F G 4 ( ω o ⋅ n ) ( ω i ⋅ n ) f_{cook-torrance}=\frac{DFG}{4(\omega_o\cdot n)(\omega_i\cdot n)} fcooktorrance=4(ωon)(ωin)DFG

该函数包括了三个因子,分别是:法线分布函数(D)、菲涅尔方程(F)和几何方程(G)。

法线分布函数

N D F G G X T R ( n , h , α ) = α 2 π ( ( n ⋅ h ) 2 ( α 2 − 1 ) + 1 ) 2 NDF_{GGXTR}(n,h,\alpha)=\frac{\alpha^2}{\pi((n\cdot h)^2(\alpha^2-1)+1)^2} NDFGGXTR(n,h,α)=π((nh)2(α21)+1)2α2

n是法线,h是半角向量,alpha是粗糙度。

该函数返回的是[0,1]的float值,返回的值代表了微平面内有多少法线的方向与h向量一致。例如,如果该函数返回的值是0.79,那么该微平面内有79%的法线是和h是同向的。因此,若半角向量与法线方向相同,那么光线会直接进入到视角里。
在这里插入图片描述

代码:

            //法线分布函数
            float NDF (float3 n, float3 h, float a){

                float nh = max(saturate(dot(n, h)), 0.000001);
                float dsquareRoughness = lerp(0.002, 1, a);
                float denom = pi * pow(nh*nh * (dsquareRoughness -1) + 1,2);
                return dsquareRoughness/denom;

            }

注意,这里的Unity的standard shader把roughness的平方映射到了0.002到1之间,使得当roughness为0的时候,也就是完全光滑的时候,还是会留一点高光。

效果:
在这里插入图片描述

几何函数

几何函数从统计学上近似的得到了微平面间损耗光线的比率。
在这里插入图片描述
如图所示,凹凸不平的微平面内损耗了不少的光线能量,所以我们需要几何函数来求得实际反射的光线的占比。

G S c h l i c k G G X ( n , v , k ) = n ⋅ v ( n ⋅ v ) ( 1 − k ) + k G_{SchlickGGX}(n,v,k)=\frac{n\cdot v}{(n\cdot v)(1-k)+k} GSchlickGGX(n,v,k)=(nv)(1k)+knv

其中,k是alpha映射出来的值(注:直接光(direct)和间接光(IBL)的k是不同的)。
k d i r e c t = ( α + 1 ) 2 8 k_{direct}=\frac{(\alpha+1)^2}{8} kdirect=8(α+1)2
k I B L = α 2 2 k_{IBL}=\frac{\alpha^2}{2} kIBL=2α2

因为我们不但需要得到在视角方向上的实际光线比率,也要获取在光线方向上的比率,所以得到了Smith’s method.

G ( n , v , l , k ) = G s u b ( n , v , k ) G s u b ( n , l , k ) G(n,v,l,k)=G_{sub}(n,v,k)G_{sub}(n,l,k) G(n,v,l,k)=Gsub(n,v,k)Gsub(n,l,k)
代码:

//几何函数
            float GGX (float3 n, float3 v, float3 k){
                float nv = max(saturate(dot(i.normal, viewDir)), 0.000001);
                float denom = lerp(nv,1,k);

                return nv/denom;
            }

            float GSmith(float3 n, float3 v, float3 l, float3 k){
                float ggx1 = GGX(n,v,k);
                float ggx2 = GGX(n,l,k);

                return ggx1*ggx2;
            }

效果:
在这里插入图片描述

菲涅尔方程

菲涅尔方程:Fresnel Equation(发音为freh-nel)。
该方程描述的是被反射的光线能量占入射光线能量的比率。
在这里插入图片描述
这张图的x轴是光的波长,y轴是视线( v v v)与法线( n n n)的夹角( θ i \theta_i θi),z轴是方程的返回值。

很明显,在夹角 θ i \theta_i θi近似于90度的时候,几乎不会有折射光线,因为入射光线全部被反射了。所以,在球体的模型中,越靠近边缘,返回值越大:

在这里插入图片描述

但是菲涅尔方程过于复杂,计算量非常大,用于渲染方面十分浪费。幸运的是,Schlick给了菲涅尔方程的一个近似解:

F ( n , l ) ≈ F 0 + ( 1 − F 0 ) ( 1 − ( h ⋅ l ) ) 5 F(n,l)≈F_0 +(1−F_0)(1−(h·l) )^5 F(n,l)F0+(1F0)(1(hl))5

该方程还有一个未知的参数:F0。F0表示平面的基础反射率,它是利用所谓折射指数计算得出的。

但是浮点值并不能很好的模拟出金属材质的性质,因为金属材质对于不同波段的反射效率是不同的。

所以说,为了模拟出金属材质和电解质材质(非金属),F0需要提前计算出平面对于法向入射(F0)的反应(入射角为 0 ° 0\degree 0°)。这里嫖了一张表:

在这里插入图片描述
虚幻四给了另一个近似的拟合公式:
F ( v , h ) = F 0 + ( 1 − F 0 ) 2 ( − 5.55473 ( v ⋅ h ) − 6.98316 ) ( v ⋅ h ) F(v,h)=F_0+(1-F_0)2^{(-5.55473(v\cdot h)-6.98316)(v\cdot h)} F(v,h)=F0+(1F0)2(5.55473(vh)6.98316)(vh)
虽然牺牲了一些性能,但是效率上升了,因为exp2函数的速度比pow函数要快。

放在代码里就是:

            //菲涅尔
            float3 Fresnel(float3 v, float3 h, float3 albedo){
                float vh = max(saturate(dot(v, h)), 0.000001);
                float3 F0 = lerp(unity_ColorSpaceDielectricSpec.rgb, albedo, _Metallic);
                return (1-F0) * exp2((-5.55473 * vh - 6.98316) * vh) + F0;
            }

Unity的内置变量unity_ColorSpaceDielectricSpec定义了非金属的高光颜色和反射率,不完全为0,是一个经验值。

注意这里用的是v和h点乘而不是v和n。因为在微平面上,作高光反射的法线其实是h而不是n(参考法线分布函数)。

效果:
在这里插入图片描述

虽然是菲涅尔方程,但是却完全没有菲涅尔效应。个人认为这是为了区分金属材质和电解质材质。因为金属材质的F0并不是灰度RGB。有很多学者并没有运用v点乘h,取而代之的是他们发现l点乘h也无所大碍。但是这两种方程差别并不大。

整合:

                float d = NDF(i.normal, halfVector, roughness);
                float g = GSmith(i.normal, viewDir, lightColor, pow(perceptualRoughness+1,2)/8);
                float3 f = Fresnel(viewDir, halfVector, albedo);

                float3 specColor = d*g*f/(4*dot(viewDir, i.normal) * dot(lightDir, i.normal));
                
                float kd = OneMinusReflectivityFromMetallic(_Metallic);
                float ks = 1-kd;
                
                float3 DirectLightResult = (kd*diffColor + ks*specColor) * lightColor * nl;

OneMinusReflectivityFromMetallic是UnityStandardUtils.cginc里的函数,用来计算漫反射系数:

inline half OneMinusReflectivityFromMetallic(half metallic)
{
 // We'll need oneMinusReflectivity, so
 //   1-reflectivity = 1-lerp(dielectricSpec, 1, metallic) = lerp(1-dielectricSpec, 0, metallic)
 // store (1-dielectricSpec) in unity_ColorSpaceDielectricSpec.a, then
 //     1-reflectivity = lerp(alpha, 0, metallic) = alpha + metallic*(0 - alpha) = 
 //                  = alpha - metallic * alpha
    half oneMinusDielectricSpec = unity_ColorSpaceDielectricSpec.a;
    return oneMinusDielectricSpec - metallic * oneMinusDielectricSpec;
} 

效果:
在这里插入图片描述
添加Texture:
https://www.textures.com/download/3DScans0760/140588

效果:
在这里插入图片描述

完整代码

Shader "TechArt/PBR"
{
	Properties
	{
		_MainTex("Texture", 2D) = "white" {}
		_Tint("Tint", Color) = (1 ,1 ,1 ,1)
		[Gamma] _Metallic("Metallic", Range(0, 1)) = 0 //金属度要经过伽马校正
        
        _NormalMap("Normal Map", 2D) = "bump"{}
        _RoughnessMap("Roughness map", 2D) = "white" {}
        _HeightMap("Height Map", 2D) = "black" {}
        _Height("Height", Range(0.001,0.9)) = 0.001

		_Smoothness("Smoothness", Range(0, 1)) = 0.5
		_LUT("LUT", 2D) = "white" {}

        _IBLTexCube("IBL Cube", Cube)= "black" {}
	}

		SubShader
	{
		Tags { "RenderType" = "Opaque" }
		LOD 100

		Pass
		{
			Tags {
				"LightMode" = "ForwardBase"
			}
			CGPROGRAM


			#pragma target 3.0

			#pragma vertex vert
			#pragma fragment frag

			#include "UnityStandardBRDF.cginc" 
            #include "UnityStandardUtils.cginc"

            #define pi 3.1415927
        

			struct appdata
			{
				float4 vertex : POSITION;
				float3 normal : NORMAL;
				float2 uv : TEXCOORD0;
                float4 tangent : TANGENT;
			};

			struct v2f
			{
				float4 vertex : SV_POSITION;
				float4 uv : TEXCOORD0;
				float3 normal : TEXCOORD1;
				float3 worldPos : TEXCOORD2;

                float4 TtoW0 : TEXCOORD3;
                float4 TtoW1 : TEXCOORD4;
                float4 TtoW2 : TEXCOORD5;
			};

			float4 _Tint;
			float _Metallic, _Height;
			float _Smoothness;
			sampler2D _MainTex, _NormalMap, _HeightMap, _RoughnessMap;
			float4 _MainTex_ST,_NormalMap_ST, _HeightMap_ST, _RoughnessMap_ST;
			sampler2D _LUT;

            samplerCUBE _IBLTexCube;

			v2f vert(appdata v)
			{
				v2f o;
                float4 heightMap = tex2Dlod(_HeightMap, float4(v.uv.xy, 0, 0));
                v.vertex *= (heightMap.r * _Height+length(v.vertex))/length(v.vertex);
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.worldPos = mul(unity_ObjectToWorld, v.vertex);
				o.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
                o.uv.zw = TRANSFORM_TEX(v.uv, _NormalMap);

                float3 worldNormal = UnityObjectToWorldNormal(v.normal);
                float3 worldTangent = UnityObjectToWorldDir(v.tangent);
                float3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;
                float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

                o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
                o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
                o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);

				o.normal = UnityObjectToWorldNormal(v.normal);
				o.normal = normalize(o.normal);
				return o;
			}

            //法线分布函数
            float NDF (float3 n, float3 h, float a){

                float nh = max(saturate(dot(n, h)), 0.000001);
                float dsquareRoughness = lerp(0.002, 1, a);
                float denom = pi * pow(nh*nh * (dsquareRoughness -1) + 1,2);
                return dsquareRoughness/denom;

            }

            //几何函数
            float GGX (float3 n, float3 v, float3 k){
                float nv = max(saturate(dot(n, v)), 0.000001);
                float denom = lerp(nv,1,k);

                return nv/denom;
            }

            float GSmith(float3 n, float3 v, float3 l, float3 k){
                float ggx1 = GGX(n,v,k);
                float ggx2 = GGX(n,l,k);

                return ggx1*ggx2;
            }

            //菲涅尔
            float3 Fresnel(float3 v, float3 h, float3 albedo){
                float vh = max(saturate(dot(v, h)), 0.000001);
                float3 F0 = lerp(unity_ColorSpaceDielectricSpec.rgb, albedo, _Metallic);
                return (1-F0) * exp2((-5.55473 * vh - 6.98316) * vh) + F0;
            }

			fixed4 frag(v2f i) : SV_Target
			{
                fixed3 normal = UnpackNormal(tex2D(_NormalMap, i.uv.zw));
                normal = normalize(half3(dot(i.TtoW0, normal), dot(i.TtoW1, normal), dot(i.TtoW2, normal)));


                float3 lightDir = normalize(_WorldSpaceLightPos0.xyz);
                float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
                float3 lightColor = _LightColor0.rgb;
                float3 halfVector = normalize(lightDir + viewDir);

                float perceptualRoughness = tex2D(_RoughnessMap, i.uv.xy).r;

                float roughness = perceptualRoughness * perceptualRoughness;
                float squareRoughness = roughness * roughness;

                float nl = max(saturate(dot(normal, lightDir)), 0.000001);
                float vh = max(saturate(dot(viewDir, halfVector)), 0.000001);

                //漫反射
                float3 albedo = _Tint * tex2D(_MainTex, i.uv);
                float3 diffColor = albedo;


                float d = NDF(normal, halfVector, roughness);
                float g = GSmith(normal, viewDir, lightColor, pow(perceptualRoughness+1,2)/8);
                float3 f = Fresnel(viewDir, halfVector, albedo);

                float3 specColor = d*g*f/(4*dot(viewDir, normal) * dot(lightDir, normal));
                
                float kd = OneMinusReflectivityFromMetallic(_Metallic);
                float ks = 1-kd;
                
                float3 DirectLightResult = (kd*diffColor + ks*specColor) * lightColor * nl;

                //间接光部分  
                float3 iblDiffuseResult = 0;
                float3 iblSpecularResult = 0;
                float3 IndirectResult = iblDiffuseResult + iblSpecularResult;

                float4 result = float4(DirectLightResult + IndirectResult, 1);

                return result;
            }

			ENDCG
		}
	}
}

直接光部分到此结束,在接下来的一篇文章中我将探讨间接光的计算。

参考资料:
https://www.qiujiawei.com/solid-angle/
https://learnopengl-cn.github.io/07%20PBR/01%20Theory/
https://blog.uwa4d.com/archives/USparkle_PBR.html
https://zhuanlan.zhihu.com/p/33464301

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值