Unity SRP 实战(一)延迟渲染与 PBR
代码放这里了:前言
换了新电脑,终于带的动了,来学 Unity!
学 Unity 第一天:下载,安装,摸了
学 Unity 第二天:看看 shader 教程,摸了
学 Unity 第三天:表弟叫打 LOL,摸了
学 Unity 第四天:看看渲染管线和 SRP 文档,摸了
学 Unity 第五天:同学叫打 LOL,摸了
学 Unity 第六天:朋友聚会,摸了
学 Unity 第七天:打原神,摸了
既然学了一周我们甚至 fw 到连一个三角形也画不出来,那么今天就来搭建一个自定义的延迟渲染管线,并且在此基础上实现 PBR 光照模型。既是对 unity 的入门,也为后续的学习做铺垫。菜鸡我刚学开始 Unity,有什么写的不对的地方还请大佬们多多包涵。目前这个简陋管线的效果如下:
项目创建与配置
环境:win 11,Unity 2021.2,DX11
从最基本 3D 项目开始创建,但是先进行一些设置:在 Edit > Project Settings > Player > Other Settings 中将项目的颜色空间(Color Space)从 Gamma 改为 Linear;将相机的 MSAA 关闭;放几个 GameObject,此时我们用的仍然是内置渲染管线(Build-in Render Pipeline),这是我们第一次,也是最后一次见到它:
可编程渲染管线入门
可编程渲染管线(Scriptable Render Pipeline,下文简写为 SRP)是 Unity 允许用户创建的高度自定义的渲染管线。Unity 向用户提供图形命令接口,用户通过这些接口发出图形命令、指挥渲染管线、控制每一次绘制。和我们往常使用 OpenGL,DX 等图形 API 类似。Unity 官方文档 中对于 SRP有非常详细的描述。
从零开始的图形学编程是十分麻烦的。我们需要管理相机、矩阵、网格资源、材质、着色器,还要指挥图形 API 进行绘制。但是 Unity SRP的出现允许像我这样的初学者将有限的精力专注在渲染上,而不必实现那些已有的轮子(比如读取 Obj 文件或者是创建一张贴图)
渲染管线是 Unity 中的一个对象,Unity 通过调用自定义渲染管线对象的 Render 函数来执行我们自定义的代码。Unity 为我们提供了 RenderPipeline 基类,自定义的渲染管线都应该继承它,并且实现 Render 函数,该函数在渲染的时候会被调用。创建 ToyRenderPipeline.cs 并且编写如下代码,我们暂且啥也不干:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEditor;
public class ToyRenderPipeline : RenderPipeline
{
protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
}
}
此时一个新鲜的 SRP 已经完成,可是回到 Unity Editor 界面(或者是 Game View 界面)我们的画面没有发生任何变化。这是因为我们没有把 SRP 挂载到 Unity 中。要建立 Unity 和 SRP 的联系,需要通过 Asset 来实现。Unity 为 SRP 的开发者提供了 RenderPipelineAsset 基类作为渲染管线资产嵌入 Unity 的桥梁。
和上文的 Render 函数类似,任何继承自 RenderPipelineAsset的类必须实现 CreatePipeline 函数,并且在该函数中返回我们自定义的渲染管线对象。Unity 内部会调用 CreatePipeline 从而获取我们自定义的 SRP 实例,进而调用 Render 函数以执行自定义的绘制命令。创建 ToyRenderPipelineAsset.cs 并编写如下代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
public class ToyRenderPipelineAsset : RenderPipelineAsset {
protected override RenderPipeline CreatePipeline() {
return new ToyRenderPipeline();
}
}
此时在菜单栏点击 Create 就可以创建一个 Render Pipeline Asset,它可以作为一种资产在目录下被找到。在 Edit > Project Settings > Graphics中可以选择我们自定义的渲染管线了:
如果画面消失,说明我们成功将项目的渲染管线从 Build-in 换成了我们自定义的 SRP。因为在 Render 函数中我们啥也没画:
接下来需要在刚刚定义的 SRP,也就是 ToyRenderPipeline 类的 Render 函数中编写 C # 代码来画点东西。Render 函数提供了两个必要的参数,在正式绘制之前不妨先来了解一下这两个参数是啥玩意。
首先是 ScriptableRenderContext,它负责接受一系列图形命令。有点像 vulkan 里面的 CommandQueue 机制,提交的命令并不会立即执行,而是等到我们调用 Submit 的时候按照顺序执行这些命令。我们可以使用 CommandBuffer 来配置若干条图形命令,然后通过 ScriptableRenderContext 对象的 ExecuteCommandBuffer 函数来(异步)执行图形命令。然后是一个 Camera[] 表示相机的数组,因为大多数情况下我们的场景都只有一个主相机,所以取 cameras 当作 Main Camera 即可。
接着可以开始绘制,通过 ScriptableRenderContext 对象的 DrawRenderers 函数进行绘制。该函数接受三个参数,分别是剔除结果、绘制设置、过滤设置。其中剔除参数(cullingResults)按照主相机的视锥体和物体的 Bounding Box 剔除不可见的物体。绘制设置(DrawingSettings)又分为两部分,第一部分是 ShaderTagId,决定了使用哪个 shader 进行绘制。第二部分是 sortingSettings,它描述绘制几何体的顺序。过滤设置(filteringSettings)充当掩码,负责剔除掉视锥体内一些特定的物体。
按照官方文档的代码,在 ToyRenderPipeline 类的 Render 函数中,为 DrawRenderers 准备三个参数就能很轻易地可以完成物体的绘制。这里设置 ShaderTagId 时选择 Lightmod 为 gbuffer 的 shader 进行绘制,其他的设置按照默认的来,最后提交图形命令。这里顺便把天空盒和 Gizmos 给画了:
// 主相机
Camera camera = cameras;
context.SetupCameraProperties(camera);
CommandBuffer cmd = new CommandBuffer();
cmd.name = "gbuffer";
// 清屏
cmd.ClearRenderTarget(true, true, Color.red);
context.ExecuteCommandBuffer(cmd);
// 剔除
camera.TryGetCullingParameters(out var cullingParameters);
var cullingResults = context.Cull(ref cullingParameters);
// config settings
ShaderTagId shaderTagId = new ShaderTagId("gbuffer"); // 使用 LightMode 为 gbuffer 的 shader
SortingSettings sortingSettings = new SortingSettings(camera);
DrawingSettings drawingSettings = new DrawingSettings(shaderTagId, sortingSettings);
FilteringSettings filteringSettings = FilteringSettings.defaultValue;
// 绘制
context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
// skybox and Gizmos
context.DrawSkybox(camera);
if (Handles.ShouldRenderGizmos())
{
context.DrawGizmos(camera, GizmoSubset.PreImageEffects);
context.DrawGizmos(camera, GizmoSubset.PostImageEffects);
}
// 提交绘制命令
context.Submit();
因为指定了 LightMode 为 gbuffer 的 shader 进行绘制(当然这里我们没有真正压 gbuffer,名字随便取一个别的也可以)所以我们要编写与之对应的着色器,在着色器中声明 Tags { "LightMode"="gbuffer" } 即可。编写 gbuffer.shader 文件,我们不考虑光照而直接输出颜色纹理,可以参考 Unity 的 Unlit shader 的写法:
Shader "ToyRP/gbuffer"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
Tags { "LightMode"="gbuffer" }
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float3 normal : NORMAL;
};
sampler2D _MainTex;
float4 _MainTex_ST;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.normal = UnityObjectToWorldNormal(v.normal);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
return col;
}
ENDCG
}
}
}然后创建一些材质,并挂上刚刚编写的 shader,再选几张纹理随便贴上去:
然后将材质挂到场景中的物体上面,这下我们的管线能够正确渲染无光照的物体了:
自定义延迟渲染管线
延迟渲染需要在 gbuffer 阶段将 Albedo、Normal、材质信息存入 Gbuffer 中,如果要用 TAA 还要存 Motion Vector 等信息。在后续计算光照的时候从 Gbuffer 中取出需要的信息,于是计算光照的复杂度就和场景结构无关。
实现延迟渲染管线的第一步是规划和构造 Gbuffer,要做到这一点需要 Multi Render Target(MRT)技术。MRT 允许像素着色器输出多个颜色,他们被暂存到一组纹理缓冲区中,以便后续光照阶段使用。
首先简单规划下 Gbuffer 的结构,一共有 4 块颜色缓冲和一块深度缓冲。其中 GT0 使用 ARGB32 格式作为 Albedo。GT1 则直接照抄 Unity 使用 ARGB2101010 格式存储世界空间下的法线。GT2 使用 ARGB64 格式,RG 存 Motion Vector,BA 存 roughness 和 metallic,16 bit per channel 足够保证精度。GT3 使用 ARGBFloat 格式,其中 RGB 存储 emission color 而 A 存储 occlusion,因为之后想支持 HDR 和 Bloom 所以需要更高的精度。这里简单糊一张 gbuffer 自定义的图:
云了一个 Gbuffer 那么再看下 Unity 怎么实现 MRT,查阅文档发现 CommandBuffer 对象的 SetRenderTarget 函数直接就能将输出指定到一组目标纹理上。给定一组颜色附件的纹理 ID 和深度缓冲的 ID 即可完成改绑定。和 OpenGL 中的 glDrawBuffers 有点点像:
首先我们需要创建颜色缓冲附件,并且获取他们的纹理 ID,这可以在 RenderPipeline 对象的构造函数中进行。我们首先在 ToyRenderPipeline 对象内部声明了 4 个颜色纹理和一个深度纹理,然后是颜色纹理的 ID。在构造函数中我们根据屏幕分辨率创建 4 个纹理,同时赋值 4 个 ID,代码如下:
RenderTexture gdepth; // depth attachment
RenderTexture[] gbuffers = new RenderTexture; // color attachments
RenderTargetIdentifier[] gbufferID = new RenderTargetIdentifier; // tex ID
public ToyRenderPipeline()
{
// 创建纹理
gdepth= new RenderTexture(Screen.width, Screen.height, 24, RenderTextureFormat.Depth, RenderTextureReadWrite.Linear);
gbuffers = new RenderTexture(Screen.width, Screen.height, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear);
gbuffers = new RenderTexture(Screen.width, Screen.height, 0, RenderTextureFormat.ARGB2101010, RenderTextureReadWrite.Linear);
gbuffers = new RenderTexture(Screen.width, Screen.height, 0, RenderTextureFormat.ARGB64, RenderTextureReadWrite.Linear);
gbuffers = new RenderTexture(Screen.width, Screen.height, 0, RenderTextureFormat.ARGBFloat, RenderTextureReadWrite.Linear);
// 给纹理 ID 赋值
for(int i=0; i<4; i++)
gbufferID = gbuffers;
}
然后在 Render 函数中,CommandBuffer 用 ClearRenderTarget 清屏之前调用 SetRenderTarget 以将着色器的输出绑定到 gbuffer 纹理中:
cmd.SetRenderTarget(gbufferID, gdepth);
还需要修改像素着色器中的代码以实现多个输出。和顶点着色器阶段类似,Unity 准备了 SV_Target 让用户指定变量以输出到特定的纹理缓冲。其中 out float4 GT0 : SV_Target0 代表了给 GT0 变量赋值即可实现写入 0 号纹理缓冲。此时我们还没有实现 PBR 材质的读取和 Motion Vector 的计算,因此 GT2、GT3 输出黄、蓝纯色作为测试。像素着色器的代码如下:
void frag (
v2f i,
out float4 GT0 : SV_Target0,
out float4 GT1 : SV_Target1,
out float4 GT2 : SV_Target2,
out float4 GT3 : SV_Target3)
{
float3 color = tex2D(_MainTex, i.uv).rgb;
float3 normal = i.normal;
GT0 = float4(color, 1);
GT1 = float4(normal*0.5+0.5, 0);
GT2 = float4(1,1,0,1);
GT3 = float4(0,0,1,1);
}这样一来就算压完 Gbuffer 了。尽管现在屏幕上没有画面,但是打开 Frame Debugger 还是能够看到我们向纹理缓冲写入的 Gbuffer 数据的。通过切换不同的 RT 来查看数据:
<blockquote data-pid="y1yjeVaJ"> 注:
这里和下面我截图的时候法线忘记 encode 了所以看起来不太正常,懒得回去截图了 (*)!!
法线在压 Gbuffer 的时候需要 × 0.5 再 + 0.5 以将 [-1, 1] 映射到 范围。在解码的时候在变回去就完了
最后将这几个纹理设置为全局纹理,在后续的任意着色器中都可以访问了:
// 设置 gbuffer 为全局纹理
cmd.SetGlobalTexture(&#34;_gdepth&#34;, gdepth);
for(int i=0; i<4; i++)
cmd.SetGlobalTexture(&#34;_GT&#34;+i, gbuffers);
在拿到了有 Gbuffer 数据的纹理之后, 还需要绘制全屏四边形,利用纹理信息计算光照。CommandBuffer 提供了 Blit 函数可以很方便的完成这一点。Blit 函数通常被用来实现后处理或者图像处理。它接受一个源纹理、一个目标纹理和一个材质。将源纹理设为 _MainTex 然后使用材质对应的 shader 将颜色输出到目标纹理。
这里我们的 gbuffer 已经是全局纹理了,只需要将目标纹理指定到相机的输出 Target 即可向屏幕输出数据。我们封装每一个 Pass,然后在 Render 函数中调用 LightPass 函数进行绘制以方便管理。这里指定使用 ToyRP/lightpass 这个 shader 进行绘制:
void LightPass(ScriptableRenderContext context, Camera camera)
{
// 使用 Blit
CommandBuffer cmd = new CommandBuffer();
cmd.name = &#34;lightpass&#34;;
Material mat = new Material(Shader.Find(&#34;ToyRP/lightpass&#34;));
cmd.Blit(gbufferID, BuiltinRenderTextureType.CameraTarget, mat);
context.ExecuteCommandBuffer(cmd);
}
全屏四边形的代码可以参考 Unity 的 Image Effect Shader 的写法。我们编写 lightpass.shader 文件,它现在还不包含光照的计算,仅仅简单输出 GT0 的内容:
Shader &#34;ToyRP/lightpass&#34;
{
Properties
{
_MainTex (&#34;Texture&#34;, 2D) = &#34;white&#34; {}
}
SubShader
{
Cull Off ZWrite Off ZTest Always
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include &#34;UnityCG.cginc&#34;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
sampler2D _gdepth;
sampler2D _GT0;
sampler2D _GT1;
sampler2D _GT2;
sampler2D _GT3;
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_GT0, i.uv);
return col;
}
ENDCG
}
}
}当然也可以输出其他 Gbuffer 的内容以证明我们的代码没有问题:
到这一步其实已经完成基本的管线搭建。后续添加不同的 Pass 都可以通过 Command Buffer 的 Bult 函数配套对应的 shader 来完成。接下来开始编写第一个 Pass 也就是 Light Pass,我们将使用 PBR 光照模型。
PBR 材质
使用 PBR 光照模型需要配套的 PBR 材质。互联网上有很多类似的资源,比如 glTF Sample Models,可以通过 UniGLTF 插件 在 Unity 中使用 glTF 模型。任意 PBR 模型归结出来都具有几个东西:Albedo、Normal、Metallic、Roughness、Occlusion。这些信息都要被压进 gbuffer 中,所以要修改我们的 gbuffer.shader 着色器。
首先是修改着色器的 Properties 以方便读入材质。这里直接照抄 Unity Standard Shader 的格式。 Metallic 和 Roughness 分别存在 Metallic Map 的 R 和 A 通道中,Occlusion 则存在 Ao Map 的 G 通道。不过我们多了两个滑动条。如果不使用贴图,则可以通过这两个滑条指定整个 Object 的金属和粗糙度。代码如下:
Properties
{
_MainTex (&#34;Albedo Map&#34;, 2D) = &#34;white&#34; {}
_Metallic_global (&#34;Metallic&#34;, Range(0, 1)) = 0.5
_Roughness_global (&#34;Roughness&#34;, Range(0, 1)) = 0.5
_Use_Metal_Map (&#34;Use Metal Map&#34;, Float) = 1
_MetallicGlossMap (&#34;Metallic Map&#34;, 2D) = &#34;white&#34; {}
_EmissionMap (&#34;Emission Map&#34;, 2D) = &#34;black&#34; {}
_OcclusionMap (&#34;Occlusion Map&#34;, 2D) = &#34;white&#34; {}
_Use_Normal_Map (&#34;Use Normal Map&#34;, Float) = 1
_BumpMap (&#34;Normal Map&#34;, 2D) = &#34;bump&#34; {}
}效果大概是这样:
然后在像素着色器中要把材质信息压入 Gbuffer,这部分的代码相当无聊,就是倒腾数据。这里我懒得弄法线贴图和 Motion Vector 了,前者直接用 Mesh 顶点法线而后者填 0。这里要注意下 Unity 默认格式中 Metallic Map 的 A 通道是 “光滑度”而我们需要 “粗糙度” 所以要进行一下转换:
sampler2D _MainTex;
sampler2D _MetallicGlossMap;
sampler2D _EmissionMap;
sampler2D _OcclusionMap;
sampler2D _BumpMap;
float _Use_Metal_Map;
float _Use_Normal_Map;
float _Metallic_global;
float _Roughness_global;
...
void frag (
v2f i,
out float4 GT0 : SV_Target0,
out float4 GT1 : SV_Target1,
out float4 GT2 : SV_Target2,
out float4 GT3 : SV_Target3
)
{
float4 color = tex2D(_MainTex, i.uv);
float3 emission = tex2D(_EmissionMap, i.uv).rgb;
float3 normal = i.normal;
float metallic = _Metallic_global;
float roughness = _Roughness_global;
float ao = tex2D(_OcclusionMap, i.uv).g;
if(_Use_Metal_Map)
{
float4 metal = tex2D(_MetallicGlossMap, i.uv);
metallic = metal.r;
roughness = 1.0 - metal.a;
}
//if(_Use_Normal_Map) normal = UnpackNormal(tex2D(_BumpMap, i.uv));
GT0 = color;
GT1 = float4(normal, 0);
GT2 = float4(0, 0, roughness,metallic);
GT3 = float4(emission, ao);
}此时导入 glTF 模型,可以在 Frame Debugger 中看到 gbuffer 的各个通道都已充满正确的数据。从上到下从左到右分别是 颜色、法线、粗糙度、金属度、自发光、Ao 贴图的数据。这里背景 Clear Color 是灰色是方便截图对照,其实应该是纯黑才对:
PBR 理论回顾
忽略自发光的话,渲染方程是以递归形式定义的:
其中:
[*]https://www.zhihu.com/equation?tex=L_o%28p%2C+w_o%29 表示从 p 点沿着 wo 方向出射的光的颜色
[*]https://www.zhihu.com/equation?tex=L_i%28p%2C+w_i%29 表示从 p 点沿着 wi (的反方向)入射的光的颜色
[*] 代表 BRDF,描述了光线从 wi 入射到 p 点再反射到 wo 方向上,还剩下多少能量
[*]https://www.zhihu.com/equation?tex=n 代表 p 点面积微元的法向量
对光线追踪足够熟悉的话,能够很快理解这些概念,这里简单复习一下渲染方程是怎么来的。最直观的理解是我们看到的颜色,都是经过反射得到的。首先可以求解 p 点从 wi 方向上接受了多少光,将 p 点看作面积微元,在入射光的方向上做投影:
不难得到 p 点接受到 wi 方向上的光强为:
https://www.zhihu.com/equation?tex=+L_i%28p%2C+w_i%29n+%5Ccdot+w_i++%5C%5C
知道了 p 有多少光,还要计算实际能从 wo 方向上出射并最终进入我们眼球的光强是多少。而双向反射分布函数(BRDF)也就是上面的提供了这个数值。于是单条光路的渲染方程为:
https://www.zhihu.com/equation?tex=+f_r%28p%2C+w_i%2C+w_o%29L_i%28p%2C+w_i%29n+%5Ccdot+w_i++%5C%5C
渲染程求解的是所有光路的总和,即穷举每一个入射方向 wi 并且分别计算每条光路的和,最后加到一起。通过对入射方向的立体角 wi 在半球面做积分能够完成这个任务。于是最终的渲染方程如下:
PBR 直接光照
我们采用 Learn OpenGL 中同样的方案,使用 Cook-Torrance 作为 BRDF 函数。比起迪士尼原则的 BRDF,Cook-Torrance 计算更快而且仅需金属度和粗糙度两个参数就能取得不错的效果。它分为漫反射和镜面反射两个部分:
https://www.zhihu.com/equation?tex=+f_r+%3D+k_d+%5C+f_%7Bdiffuse%7D+%2B+k_s+%5C+f_%7Bspecular%7D+%5C%5C
通过 ks kd 俩系数进行组合。因为光线不是漫反射就是镜面反射,这里取 https://www.zhihu.com/equation?tex=k_d%3D%281-k_s%29%2A%281-metallic%29 ,而菲涅尔项 F 就是描述反射光占比的,也就是 ks 系数,因此它被包含在了镜面反射的 BRDF 中所以暂且不管它。回到 BRDF 上,漫反射的部分直接用 Lambert Diffuse :
https://www.zhihu.com/equation?tex=++f_%7Bdiffuse%7D+%3D+%5Cfrac%7BAlbedo%7D%7B%5Cpi%7D++%5C%5C
镜面反射部分则略复杂。注意这里我们用 https://www.zhihu.com/equation?tex=n%2C+v%2C+h%2C+l 代指上文的https://www.zhihu.com/equation?tex=w_i%2Cw_o等参数。如果了解迪士尼原则的 BRDF 那篇文章的话不难理解这些概念。不过和迪斯尼不一样这里我们 D 用各向同性的 Trowbridge-Reitz GGX,F 用 Schlick-Fresnel,而 G 用 Schlick-GGX :
https://www.zhihu.com/equation?tex=++f_%7Bspecular%7D%3D%5Cfrac%7BD+%5C+F%5C+G%7D%7B4%28n+%5Ccdot+v%29%28n+%5Ccdot+l%29%7D+%5C%5C
其中 D 是微表面法线分布,描述了镜面反射的出射波瓣的能量分布。参数 是粗糙度的平方做一个对美术而言更平滑的映射:
https://www.zhihu.com/equation?tex=+%5Calpha%3Droughness%5E2+%5C%5C+D+%3D+%5Cfrac%7B%5Calpha%5E2%7D%7B%5Cpi%28%28n%5Ccdot+h%29%5E2%28%5Calpha%5E2-1%29%2B1%29%5E2%7D+%5C%5C
F 是菲涅尔项,它描述了反射光和透射光的比例,参数 F0 是垂直(0° 角)观察时反射光的比例。大多数金属取 0.04,而对于 Disney Principle&#39;s BRDF 他们材质多了一个 specular 参数将 F0 映射到 的区间。这里我们没这好待遇,就直接用 0.04 和 Albedo 根据 metallic 插值得到 F0 :
https://www.zhihu.com/equation?tex=+F_0+%3D+lerp%280.04%2C+Albedo%2C+metallic%29+%5C%5C+F%3DF_0%2B%281-F_0%29%281-%28h%5Ccdot+v%29%29%5E5+%5C%5C
G 是几何自遮蔽项,表示略视时因微平面引起的自遮蔽。这里分别对入射和出射计算两次 GGX 遮蔽然后相乘即可。系数 k 是通过 映射得到的,在直接光照和 IBL 中有不同的映射方案:
https://www.zhihu.com/equation?tex=++k+%3D+%5Cfrac%7B%28%5Calpha%2B1%29%5E2%7D%7B8%7D+%5C%5C+GGX%28n%2Cv%2Ck%29%3D%5Cfrac%7Bn%5Ccdot+v%7D%7B%28n%5Ccdot+v%29%281-k%29%2Bk%7D+%5C%5C+G+%3D+GGX%28n%2C+v%2C+k%29+%2A+GGX%28n%2Cl%2Ck%29+%5C%5C
虽然 Unity 已经提供了标准的函数,但是重新造一次轮子也不是不行。将这些函数转换为代码就是:
#define PI 3.14159265358
// D
float Trowbridge_Reitz_GGX(float NdotH, float a)
{
float a2 = a * a;
float NdotH2 = NdotH * NdotH;
float nom = a2;
float denom = (NdotH2 * (a2 - 1.0) + 1.0);
denom = PI * denom * denom;
return nom / denom;
}
// F
float3 SchlickFresnel(float HdotV, float3 F0)
{
float m = clamp(1-HdotV, 0, 1);
float m2 = m * m;
float m5 = m2 * m2 * m; // pow(m,5)
return F0 + (1.0 - F0) * m5;
}
// G
float SchlickGGX(float NdotV, float k)
{
float nom = NdotV;
float denom = NdotV * (1.0 - k) + k;
return nom / denom;
}至此我们凑齐了所有需要的项,就可以开始计算 PBR 直接光照。这里参数 radiance 代表光源的颜色,而 roughness 要 clamp 一下保证完全光滑的物体也有一点高光:
// 直接光照
float3 PBR(float3 N, float3 V, float3 L, float3 albedo, float3 radiance, float roughness, float metallic)
{
roughness = max(roughness, 0.05); // 保证光滑物体也有高光
float3 H = normalize(L+V);
float NdotL = max(dot(N, L), 0);
float NdotV = max(dot(N, V), 0);
float NdotH = max(dot(N, H), 0);
float HdotV = max(dot(H, V), 0);
float alpha = roughness * roughness;
float k = ((alpha+1) * (alpha+1)) / 8.0;
float3 F0 = lerp(float3(0.04, 0.04, 0.04), albedo, metallic);
floatD = Trowbridge_Reitz_GGX(NdotH, alpha);
float3 F = SchlickFresnel(HdotV, F0);
floatG = SchlickGGX(NdotV, k) * SchlickGGX(NdotL, k);
float3 k_s = F;
float3 k_d = (1.0 - k_s) * (1.0 - metallic);
float3 f_diffuse = albedo / PI;
float3 f_specular = (D * F * G) / (4.0 * NdotV * NdotL + 0.0001);
float3 color = (k_d * f_diffuse + f_specular) * radiance * NdotL;
return color;
}在 lightpass.shader 中实现光照的计算。首先在 lightpass.shader 的像素着色器中解码 Gbuffer 获得材质信息:
float2 uv = i.uv;
float4 GT2 = tex2D(_GT2, uv);
float4 GT3 = tex2D(_GT3, uv);
// 从 Gbuffer 解码数据
float3 albedo = tex2D(_GT0, uv).rgb;
float3 normal = tex2D(_GT1, uv).rgb * 2 - 1;
float2 motionVec = GT2.rg;
float roughness = GT2.b;
float metallic = GT2.a;
float3 emission = GT3.rgb;
float occlusion = GT3.a;
float d = UNITY_SAMPLE_DEPTH(tex2D(_gdepth, uv));
float d_lin = Linear01Depth(d);紧接着计算光照需要的参数,最后完成光照的计算。在计算 N、V、L、H 等参数时我们不可避免的会用到世界坐标,而 Gbuffer 中并没有这个信息。我们需要通过全屏四边形的 uv 转 Ndc 坐标,结合深度和 viewProjection 的逆矩阵反投影得到世界坐标。首先在 C # 中需要传一下相机矩阵,这里直接设置为 Global Matrix 了:
// 设置相机矩阵
Matrix4x4 viewMatrix = camera.worldToCameraMatrix;
Matrix4x4 projMatrix = GL.GetGPUProjectionMatrix(camera.projectionMatrix, false);
Matrix4x4 vpMatrix = projMatrix * viewMatrix;
Matrix4x4 vpMatrixInv = vpMatrix.inverse;
cmd.SetGlobalMatrix(&#34;_vpMatrix&#34;, vpMatrix);
cmd.SetGlobalMatrix(&#34;_vpMatrixInv&#34;, vpMatrixInv);
然后回到lightpass.shader 着色器。在像素着色器中拿到相机矩阵就可以愉快地反投影计算世界坐标了:
float4x4 _vpMatrix;
float4x4 _vpMatrixInv;
...
// 反投影重建世界坐标
float4 ndcPos = float4(uv*2-1, d, 1);
float4 worldPos = mul(_vpMatrixInv, ndcPos);
worldPos /= worldPos.w;最后在着色器中输出 PBR 直接光照的颜色。这里通过_WorldSpaceLightPos0 得到主光源(平行光)方向,通过 _WorldSpaceCameraPos 得到相机的世界坐标,通过 _LightColor0 得到光源颜色,最后传进光照函数完成最终的着色:
// 计算参数
float3 N = normalize(normal);
float3 L = normalize(_WorldSpaceLightPos0.xyz);
float3 V = normalize(_WorldSpaceCameraPos.xyz - worldPos.xyz);
float3 radiance = _LightColor0.rgb;
// 计算光照
float3 color = PBR(N, V, L, albedo, radiance, roughness, metallic);
color += emission;
return float4(color, 1);回到编辑器看看效果:
有几个小问题:
[*]和 Unity Standard Shader 对比,我们的光照颜色有些暗
[*]Gizmos 和天空盒不见了
对于前者很好解决,这是因为 Unity 为了兼容旧版本,它的 diffuse 没有除以 PI 而我们除了,最后给他乘回去就完了:
这样一来第一个问题就解决了。而第二个问题稍微麻烦一点,首先需要将天空盒和 Gizmos 放到最后绘制,但是这样一来最后绘制天空盒就会覆盖掉原来的画面。一个解决办法是在 lightpass.shader 着色器中启用并写入深度缓冲,用 Gbuffer 深度覆盖全屏四边形的深度。这会导致绘制天空的时候被遮挡的部分无法通过 Z Test 从而不会覆盖先前的颜色:
至此我们的玩具管线已经能够正确处理单一平行光源的直接光照了。目前场景仍然没有间接光照,金属球因为大部分光来自 specular 所以颜色很暗,而头盔的金属部分也显得暗淡:
关闭环境光和反射,仅有 Direction Light 做光源。用我们的管线和 Unity Standard Shader 对比看看,结果基本正确。这里我们的管线 Diffuse 偏暗了,查阅资料发现 Unity 的 Diffuse 用的是 Disney 的那套而不是 Lambert 所以它的要亮一点:
IBL 间接光照
基于图像的光照(Image Based Lighting,IBL)是一种实时、快速的间接光照解决方案。通常用一张 .hdr 格式的立方体贴图来表示环境的辐射度:
仍然从渲染方程入手,间接光照同样分为 Diffuse 和 Specular 两部分:
https://www.zhihu.com/equation?tex=+L_o%28p%2C+w_o%29+%3D++%5Cint_%5COmega%7B%5Cfrac%7BAlbedo%7D%7B%5Cpi%7DL_i%28p%2C+w_i%29n+%5Ccdot+w_i+%5C+d+w_i%7D+%2B+%5Cint_%5COmega%7B%5Cfrac%7BD+%5C+F%5C+G%7D%7B4%28n+%5Ccdot+w_o%29%28n+%5Ccdot+w_i%29%7DL_i%28p%2C+w_i%29n+%5Ccdot+w_i+%5C+d+w_i%7D+%5C%5C
对于漫反射 BRDF 为常数,积分结果只取决于法向量 n 故可以离线建立查找表。对 n 的每个不同取值,穷举 wi 进行积分并将结果存 CubeMap,熟悉光线追踪的话不难理解这个过程。在实时计算时给定表面法线 n,用 n 去查 CubeMap 即可获得漫反射颜色。预积分的漫反射查找表如下:
对于镜面反射则稍微复杂一些,因为积分的结果不仅和法向量 n 有关,还和视线方向 v 有关。穷举两个向量的组合建立查找表代价过大,因此采用近似的方案。积分被分为环境辐射度和 BRDF 两部分:
https://www.zhihu.com/equation?tex=++%5Cint_%5COmega%7B%5Cfrac%7BD+%5C+F%5C+G%7D%7B4%28n+%5Ccdot+v%29%28n+%5Ccdot+l%29%7DL_i%28p%2C+w_i%29n+%5Ccdot+w_i+%5C+d+w_i%7D++%5Capprox++%5Cint_%5COmega%7B+L_i%28p%2C+w_i%29+%5C+d+w_i%7D+%2A+%5Cint_%5COmega%7B%5Cfrac%7BD+%5C+F%5C+G%7D%7B4%28n+%5Ccdot+w_o%29%28n+%5Ccdot+w_i%29%7Dn+%5Ccdot+w_i+%5C+d+w_i%7D+%5C%5C
第一个预积分和漫反射类似。不同的是漫反射随机发射光线,而这里使用粗糙度进行重要性采样生成光线方向。穷举不同的粗糙度进行预积分,并且把积分结果按照粗糙度存到不同 Level 的 Mipmap 中。实时计算时根据粗糙度映射一个 LOD,取最接近的两个 Mip Level 进行采样,然后在两个结果中插值得到最终的结果。预积分的镜面反射查找表如下:
而第二个积分稍微复杂。因为 BRDF 和粗糙度、N、V、L 都有关,需要用到一些技巧。首先将菲涅尔项 F 用 Schlick Fresnel 近似替换,并且约掉分子分母中的https://www.zhihu.com/equation?tex=n+%5Ccdot+w_i 得到:
https://www.zhihu.com/equation?tex=+%5Cint_%5COmega%7B%5Cfrac%7BD+%5C+%28F_0%2B%281-F_0%29%281-w_o%5Ccdot+h%29%5E5%29+%5C+G%7D%7B4+%5C+%28+n+%5Ccdot+w_o%29%7D+%5C+d+w_i%7D+%5C%5C
因为 F0 是常数所以可以提出来,这样一来对 BRDF 的积分变为对 https://www.zhihu.com/equation?tex=k+%2A+%5C+F0%2Bb 的线性变换。我们需要分别算出两个积分的值。同样用重要性采样生成光线:
https://www.zhihu.com/equation?tex=++F_0%5Cint_%5COmega%7B%5Cfrac%7BD+%5C+%281-%281-w_o%5Ccdot+h%29%5E5%29+%5C+G%7D%7B4+%5C+%28+n+%5Ccdot+w_o%29%7D+%5C+d+w_i%7D+%2B+%5Cint_%5COmega%7B%5Cfrac%7BD+%5C+%281-w_o%5Ccdot+h%29%5E5+%5C+G%7D%7B4+%5C+%28+n+%5Ccdot+w_o%29%7D+%5C+d+w_i%7D+%5C%5C
现在积分取决于 V,NdotL 和粗糙度,一种可取但是内存开销较大的方案是使用 3D 查找表。更加贪婪的方案是令 N=V=R 从而移除视线方向 V 的因素。因为镜面反射的波瓣在大多数角度都是各向同性的,仅仅在掠视的时候呈各向异性:
在大多数情况下这种近似带来的性能提升远大于它的 Artifact,现在仅需要两个参数就可以建立 2D 查找表。用 NdotV 和 roughness 做 uv 采样 LUT 的 RG 通道就能得到两个积分的值:
预计算 IBL 贴图
完整的预积分代码网上数不胜数。作为顶级懒狗 + 摆子,我是肯定不会自己动手写的。BRDF 的 LUT 直接偷的 Learn OpenGL 的图,虽然 png 文件只有 8 bit per channel 但是实测了下精度还是凑合的。对于漫反射预积分贴图可以通过 cmftStudio 这款优秀的开源工具进行预计算。对于镜面反射预积分贴图 Unity 自带了过滤器帮我们计算了各级 Mipmap,只需鼠标点点:
生成了贴图之后导入 Asset 就能调整上述属性:
除此之外还要把这些贴图传入我们的管线。这里用一个相当丑陋的方法,在 ToyRenderPipeline 中声明 public 变量,然后通过ToyRenderPipelineAsset 传入并且设为全局纹理让着色器读取:
一旦为ToyRenderPipelineAsset 设置 public 变量,就能在管线资产的检视界面中选择纹理了:
最后一块拼图
编写计算间接光照的函数!这将会是 PBR Boss 的 Final Stage,也意味着这篇又臭又长的笔记接近尾声。So, talk is cheap and here is the code ...
// Unity Use this as IBL F
float3 FresnelSchlickRoughness(float NdotV, float3 f0, float roughness)
{
float r1 = 1.0f - roughness;
return f0 + (max(float3(r1, r1, r1), f0) - f0) * pow(1 - NdotV, 5.0f);
}
// 间接光照
float3 IBL(
float3 N, float3 V,
float3 albedo, float roughness, float metallic,
samplerCUBE _diffuseIBL, samplerCUBE _specularIBL, sampler2D _brdfLut)
{
roughness = min(roughness, 0.99);
float3 H = normalize(N); // 用法向作为半角向量
float NdotV = max(dot(N, V), 0);
float HdotV = max(dot(H, V), 0);
float3 R = normalize(reflect(-V, N)); // 反射向量
float3 F0 = lerp(float3(0.04, 0.04, 0.04), albedo, metallic);
// float3 F = SchlickFresnel(HdotV, F0);
float3 F = FresnelSchlickRoughness(HdotV, F0, roughness);
float3 k_s = F;
float3 k_d = (1.0 - k_s) * (1.0 - metallic);
// 漫反射
float3 IBLd = texCUBE(_diffuseIBL, N).rgb;
float3 diffuse = k_d * albedo * IBLd;
// 镜面反射
float rgh = roughness * (1.7 - 0.7 * roughness);
float lod = 6.0 * rgh;// Unity 默认 6 级 mipmap
float3 IBLs = texCUBElod(_specularIBL, float4(R, lod)).rgb;
float2 brdf = tex2D(_brdfLut, float2(NdotV, roughness)).rg;
float3 specular = IBLs * (F0 * brdf.x + brdf.y);
float3 ambient = diffuse + specular;
return ambient;
}有几个要注意的地方:首先粗糙度要映射到 Mip Level,这里直接抄 Unity 的映射。其次 LUT 在 NdotV 和 roughness 都为 1 的时候会有 bug 故这里要 clamp 一下,最后菲涅尔的公式要改一下,加一个粗糙度魔数否则边缘会变得很黑。这些经验公式纯粹为了在视觉上和 Unity 对齐,它们来自于其他使用 Unity 的前辈 Orz
嗯 ... 经过相当多的步骤,我们终于来到了这一步。尽管我们还未实现法线贴图和抗锯齿,但结果仍然令人兴奋。熠熠生辉的球体意味着我们的努力并非白费。再贴下和 Unity 标准实现的对比,诚然用 QQ 截图逐像素比对还是存在差异,但是我已经尽力在向 Build-in 管线靠拢了:
后记
这篇笔记算是 2022 年的第一篇笔记,也是在知乎发的第一篇。之前都是在 CSDN 旧博客 发的,以前不懂事灌了很多水不过置顶的几篇质量还算过关,大伙有兴趣可以看看。
话说回来 Unity 还是真好上手。省去了一大堆的模型、材质管理,并且提供了好用的编辑器。另外 SRP 的 API 也很方便,几条代码就可以 Draw Call,不过今天姑且只是囫囵的学了下,还未深入理解引擎内部的剔除、排序、透明物体绘制等功能。学习的路还有很长 ...
参考与引用
JoeyDeVries, &#34;Learn OpenGL&#34;
王江荣, &#34;【Unity】SRP简单入门&#34;
銀葉吉祥, &#34;一起来写Unity渲染管线吧!&#34;
MaxwellGeng, &#34;在Unity里写一个纯手动的渲染管线&#34;
Catlike Coding, &#34;Custom Render Pipeline&#34;
Unity Manual, &#34;Scriptable Render Pipeline&#34;
郭大钦, &#34;unity build-in管线中的PBR材质Shader分析研究&#34;
雨轩, &#34;Unity PBR Standard Shader 实现详解&#34;
taecg, &#34;零基础入门Unity Shader&#34;
Tim Cooper, &#34;Scriptable Render Pipeline Overview&#34;
LetMe_See, &#34;Unity中的CommandBuffer&#34;
EA DICE, &#34;Moving Frostbite to PBR&#34;
cinight, &#34;CustomSRP&#34;
moriya苏蛙可, &#34;DX12渲染管线(1) - 基于物理的渲染(PBR)&#34;
pema99, &#34;shader-knowledg&#34;
dariomanesku, &#34;cmftStudio&#34; 其实你这个gt2和gt3都给太慷慨了[捂脸]
roughness和metallic各8bit绰绰有余了,实际上各5-6bit都行。metallic绝大部分都是近乎二值,roughness大的时候变化不敏感,16bit其实很多了。
而且没必要和velocity pack在一起,拆成两张图更划算。不然每次读velocity的时候也要读r和m,读r和m的时候还要读32bit的velocity,互相还影响精度,蛮不划算的[思考]
至于自发光全精度太富裕了,动态范围不算很大的话,r11g11b10其实就够了[飙泪笑] 嗯嗯 谢谢大佬提点 ~我这确实给多了 [捂脸] 自己实际使用时也发现 roughness 8 bit 完全够用。当时弄的时候比较随意,后面想想我这么划分确实太奢侈了 hhhh
页:
[1]