项目背景
这个项目应该是在去年(2022)暑假接近开学的时候做的,也是我正式开始入门图形学的时间,当时只对图形学渲染管线流程有基本的了解,Shader的编写也没有什么经验,更不清楚什么是BRDF。因为项目急着出进度,从只是实现项目需求来看,编写Shader和实现BRDF模型都挺简单。但在对基本知识缺少了解的情况下,确实出现了一些问题,比如在一开始实现菲涅尔项时给错了夹角,但因为在该项目中菲涅尔效应并不明显,甚至很长时间都没有发现…
原项目中有相当一部分与BRDF无关的开发需求,本篇文章主要记录了BRDF部分的实现情况,基本都是一些基础内容。如果时间来得及,后续应该会有一篇文章专门讲一讲PBR材质的原理部分,也是去年后续学习的部分。
BRDF简单介绍
物体的渲染的颜色受自身的材质属性和光照影响,渲染方程可以用来描述如何对一个点进行着色,如果考虑从 ω o \omega_{o} ωo方向看向 p p p点,对 p p p点进行着色:
L o ( p , ω o ) = L e ( p , ω o ) + ∫ Ω f r ( p , ω i , ω o ) L i ( p , ω i ) n ⋅ ω i d ω i L_{o}\left( {p,\omega_{o}} \right) = L_{e}\left( {p,\omega_{o}} \right) + \int_{\Omega}^{~}{f_{r}\left( {p,\omega_{i},\omega_{o}} \right)L_{i}\left( {p,\omega_{i}} \right)n \cdot \omega_{i}\mathbb{d}\omega_{i}} Lo(p,ωo)=Le(p,ωo)+∫Ω fr(p,ωi,ωo)Li(p,ωi)n⋅ωidωi
则该点的颜色分两部分:自发光 L e L_{e} Le和来自其他所有方向的光 L i L_{i} Li分配到 ω o \omega_{o} ωo的方向。公式中的 f r f_{r} fr便是描述了 p p p点在 ω i \omega_{i} ωi方向接收到的能量有多少分配到了 ω o \omega_{o} ωo方向上的函数BxDF,包括:
- BRDF,双向反射分布函数,名字抽象但却很形象,也是本篇实现的内容
- BTDF,双向透射分布函数
- BSDF,双向散射分布函数,即BRDF+BTDF
- …
忽略自发光,在不考虑间接光照的情况下,其他方向的光便是来自光源的光(平行光、点光源等简单光照模型),即最终结果便是计算每个能够影响到该点的光源对该点的光照贡献(通常会加入环境光照弥补间接光照的缺失,以避免暗处完全为黑色)。
BRDF实现
在Unity中创建一个Unlit shader,代码基本框架就已经有了,然后就是添加一些需要的参数,并在ps里按照公式实现对应的BRDF模型(注意不要在vs里实现,除开vs受场景顶点数影响的性能区别,在vs里计算光照结果线性插值后最终结果并不准确)。原项目中实现了Blinn-Phong、Neumann-Phong、Cook-Torrance、GGX、Ward五种BRDF模型,这里仅以近些年被采用较多的GGX模型为例介绍其实现,GGX分为漫反射项和镜面反射项,公式如下:
f r = f d i f f u s e + f s p e c u l a r = k d i f f π + k s p e c F ( i , h ) G ( i , o , h ) D ( h ) 4 ∣ i ⋅ n ∣ | o ⋅ n ∣ f_{r} = f_{diffuse} + f_{specular} = \frac{k_{diff}}{\pi} + k_{spec}\frac{F\left( {
{\mathbf{i}},{\mathbf{h}}} \right)G\left( {
{\mathbf{i}},{\mathbf{o}},{\mathbf{h}}} \right)D({\mathbf{h}})}{\left. 4\left| {
{\mathbf{i}} \cdot {\mathbf{n}}} \right| \middle| {\mathbf{o}} \cdot {\mathbf{n}} \right|} fr=fdiffuse+fspecular=πkdiff+kspec4∣i⋅n∣∣o⋅n∣F(i,h)G(i,o,h)D(h)
其中 n \mathbf{n} n为法线, i \mathbf{i} i为入射方向(在只考虑直接光照时可以简单理解为光源方向 L \mathbf{L} L), o \mathbf{o} o为出射方向(在只考虑直接光照时可以简单理解为视角方向 V \mathbf{V} V),均为沿着物体表面向外的单位向量, h \mathbf{h} h为 i \mathbf{i} i与 o \mathbf{o} o的半角向量,即单位化 ( i + o ) (\mathbf{i}+\mathbf{o}) (i+o)。
F F F为菲涅尔项,通常采用其近似解,这里 m \mathbf{m} m可以认为与 h \mathbf{h} h含义相同,具体原因后续PBR材质分析的文章会谈到:
F S c h l i c k ( i , m ) = F 0 + ( 1 − F 0 ) ( 1 − ( i ⋅ m ) ) 5 F_{Schlick}\left( {
{\mathbf{i}},{\mathbf{m}}} \right) = F_{0} + \left( 1 - F_{0} \right)\left( 1 - ({\mathbf{i}} \cdot {\mathbf{m}}) \right)^{5} FSchlick(i,m)=F0+(1−F0)(1−(i⋅m))5
法线分布函数 D D D和阴影遮蔽函数 G G G公式则分别如下:
D ( m ) = α 2 π c o s 4 θ ( α 2 + t a n 2 θ ) 2 , c o s θ = ( n ⋅ m ) D({\mathbf{m}}) = \frac{\alpha^{2}}{\pi{cos}^{4}\theta\left( \alpha^{2} + {tan}^{2}\theta \right)^{2}},cos\theta = ({\mathbf{n}} \cdot {\mathbf{m}}) D(m)=πcos4θ(α2+tan2θ)2α2,cosθ=(n⋅m)
G ( i , o , m ) = 4 ( 1 + 1 + α 2 t a n 2 θ 1 ) ( 1 + 1 + α 2 t a n 2 θ 2 ) , c o s θ 1 = ( i ⋅ n ) , c o s θ 2 = ( o ⋅ n ) G\left( {
{\mathbf{i}},{\mathbf{o}},{\mathbf{m}}} \right) = \frac{4}{\left( 1 + \sqrt{1 + \alpha^{2}{tan}^{2}\theta_{1}} \right)~\left( 1 + \sqrt{1 + \alpha^{2}{tan}^{2}\theta_{2}} \right)},cos\theta_{1} = ({\mathbf{i}} \cdot {\mathbf{n}}),cos\theta_{2} = ({\mathbf{o}} \cdot {\mathbf{n}}) G(i,o,m)=(1+1+α2tan2θ1) (1+1+α2tan2θ2)4,cosθ1=(i⋅n),cosθ2=(o⋅n)
这里也可以看到我们有四个参数 k d i f f k_{diff} kdiff、 k s p e c k_{spec} kspec、 F 0 F_{0} F0、 α \alpha α用来控制材质的具体表现。 k d i f f k_{diff} kdiff、 k s p e c k_{spec} kspec用于控制漫反射强度、高光反射强度, F 0 F_{0} F0用于控制菲涅尔效应,在金属与非金属之间区别明显, α \alpha α则描述了物体的粗糙程度。
附上实现代码,删除了多余的内容,相对来说比较直观易于理解。注意这里只考虑了主光源的光照、 F 0 F_{0} F0为单通道,同时如果需要投射阴影和接收阴影也需要额外添加代码进行处理(前段时间的另一个项目中也在URP管线中实现了GGX模型,在那篇文章中我也会附上相应的实现代码):
Shader "BRDF/GGX"
{
Properties
{
_Kdiff ("Kdiff", float) = 0.1
_Kspec ("Kspec", float) = 0.4
_Alpha ("Alpha", float) = 0.05
_F0 ("F0", float) = 0.8
}
SubShader
{
Tags {
"RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#define PI 3.1415926535
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 vertex : SV_POSITION;
float3 posW : TEXCOORD0;
float3 normW : TEXCOORD1;
};
float _Kdiff;
float _Kspec;
float _Alpha;
float _F0;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.posW = mul(unity_ObjectToWorld, v.vertex);
o.normW = mul(v.normal, (float3x3)unity_WorldToObject);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
float3 V = normalize(_WorldSpaceCameraPos - i.posW);
float3 L = normalize(_WorldSpaceLightPos0.xyz);
float3 H = normalize(L + V);
float3 N = normalize(i.normW);
float NdotH = dot(N, H);
float NdotL = dot(N, L);
float NdotV = dot(N, V);
float HdotV = dot(H, V);
float alpha2 = _Alpha * _Alpha;
float tanNL2 = 1 / (NdotL * NdotL + 1e-8) - 1;
float tanNV2 = 1 / (NdotV * NdotV + 1e-8) - 1;
float F = _F0 + (1 - _F0) * pow(1 - HdotV, 5);
float D = alpha2 / (PI * pow(NdotH * NdotH * (alpha2 - 1) + 1, 2));
float G = 4 / ((1 + sqrt(1 + alpha2 * tanNL2)) * (1 + sqrt(1 + alpha2 * tanNV2)));
float fr = _Kdiff / PI +
_Kspec * F * D * G / (4 * NdotL * NdotV);
return fixed4(max(0, fr) * _LightColor0.rgb * max(0, NdotL), 1.0f);
}
ENDCG
}
}
}
色彩空间Color Space
如果想得到相对正确、真实的结果,Unity的Color Space(Project Settings->Player->Other Settings)应当设置为Linear,这与屏幕亮度的非线性输出有关。
如果设置为Gamma,Unity不会对sRGB的伽马矫正进行移除,也不会在最后输出结果时进行伽马矫正,这会导致最终结果出现一些偏差,但会降低开销,因此Unity默认选择了Gamma Space(似乎新版改为了默认使用Linear Space?),毕竟很多时候图形学都是看起来没问题那就没问题,有时候过程讲究得太多,最后吃的都是性能。
HDR及Tone mapping
因为项目中需要观察高光表现,因此开启了HDR并添加了Tone mapping保留更多高光细节。
与HDR(High Dynamic Range,高动态范围)对应的是LDR(Low Dynamic Range,低动态范围),简单来说对于LDR颜色大于1的部分都被限制为1,而HDR的颜色则能够超过1(如IBL就是采用HDR实现)。但显示器的显示范围还是0到1的,因此为了显示超过1的部分就需要色调映射Tone mapping,把高亮度映射至低亮度,并保留更多的细节。
实现是直接使用的网上找来的ACES Tone mapping代码:
float3 ACES(float3 color)
{
const float a = 2.51f;
const float b = 0.03f;
const float c = 2.43f;
const float d = 0.59f;
const float e = 0.14f;
return (color * (a * color + b)) / (color * (c * color + d) + e);
}
(理论上原本应该属于后处理过程,并可以添加手动或根据渲染画面动态调整曝光值,但项目不需要,就被我直接写在BRDF shader里了)