Tech · ArtUnity

「Unity」手动延迟渲染PBRpipeline part1

by Ayse, 2022-03-13


Unity

0.png 图源Unity官网的渲染视频演示

之前一边在跟闫老师的图形学,一边学着改ue的源码,前者感觉相对学习周期要长一些而且是更加底层的架构,而后者写光照模型的编译实在是要把我逼疯了,Toonshading写了一半遂放弃,我现在最缺的就是时间!于是我看到了unity的srp——极度亲和的管线编程。如果是前向渲染,管线的编程相对来说将会简单一些,至少不需要Gbuffer的处理,逐光源计算的难度也相对来说要更接近直接思维,作为一个懒人选择了延迟渲染作为编写的目的,选择Unity来尝试手撸延迟渲染PBR顺便练习一下自己的光照模型基础。


这里有一个小坑踩了,之前在c1的版本里面后面有一些不应该出现问题的问题一直给我报错,建议要是写管线还是不能用中国特供版本。那么我们打开Unity,根据官方文档

如果要基于可编程渲染管线 (SRP) 创建自己的渲染管线,项目必须包含: 一个继承自 RenderPipelineAsset 并覆盖其 CreatePipeline() 方法的脚本。此脚本用于定义渲染管线资源。 一个继承自 RenderPipeline 并覆盖其 Render() 方法的脚本。此脚本定义渲染管线实例,是编写自定义渲染代码的地方。 一个从 RenderPipelineAsset 脚本创建的渲染管线资源。此资源充当渲染管线实例的工厂类。

我们先把管线的实例创建出来,只需要一个asset和一个instance的脚本(忍不住吐槽这实在是比ue优越太多了我麻了,之前改ue源码光是创建新的管线就要改好n个藏得很深很深的文件声明)Unity可以通过创建子类后调用asset基类中的CreatePipeline获取自定义的SRP实例

// CreateAssetMenu 属性让您可以在 Unity Editor 中创建此类的实例。
[CreateAssetMenu(menuName = "Rendering/pbrPipelineAsset")]
public class pbrPipelineAsset : RenderPipelineAsset
{
    // Unity 在渲染第一帧之前调用此方法。
    // 如果渲染管线资源上的设置改变,Unity 将销毁当前的渲染管线实例,并在渲染下一帧之前再次调用此方法。
    protected override RenderPipeline CreatePipeline()
    {
        // 实例化此自定义 SRP 用于渲染的渲染管线。
        return new pbrPipelineInstance(this);
    }
}

public class pbrPipelineInstance : RenderPipeline
{
    // 使用此变量来引用传递给构造函数的渲染管线资源
    private pbrPipelineAsset renderPipelineAsset;

    // 构造函数将 ExampleRenderPipelineAsset 类的实例作为其参数。
    public pbrPipelineInstance(pbrPipelineAsset asset)
    {
        renderPipelineAsset = asset;
    }

    protected override void Render(ScriptableRenderContext context, Camera[] cameras)
    {

        // 可以在此处编写自定义渲染代码。通过自定义此方法可以自定义 SRP。
    }
}

这些都是官网的代码,根据Unity的版本不同还会有一些差别,所以建议上官网抄一下,编译之后发现多了一个pipeline的文件。然后我们在projectsetting中修改图形渲染管线后,我们开始写render函数里面的内容。 ScriptableRenderContext context--render的输入第一个是令人困惑的,查了文档后发现它是一个Unity的定义好的来给gpu分发任务的类,(可以移步文档)它带有了一系列的指令后面我们也会用到,比较重要的是DrawRenderers函数和ExecuteCommandBuffer函数,前者是draw render call,后者可以存进一系列的render命令通过submit执行。那么开始的时候我们直接就设定相机和开辟gbuffer。 Unity里的commandbuffer类就是所有framebuffer的类,可以直接通过new来新定义。整个过程就是定义一个摄像机、一个culling、和一些必要的setting就可以实现绘制了(再次感叹)然后handles类前面还要加给UnityEditor.否则至少在2021.2版本会报错

    //camera
    Camera camera = cameras[0];
    context.SetupCameraProperties(camera);

    CommandBuffer cmd = new CommandBuffer();
    cmd.name = "gbuffer";

    //clear
    cmd.ClearRenderTarget(true, true, Color.red);
    context.ExecuteCommandBuffer(cmd);

    //culling
    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;

    // draw
    context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);

    //skybox and giz
    if (camera.clearFlags == CameraClearFlags.Skybox && RenderSettings.skybox != null)
    {
        context.DrawSkybox(camera);
    }

    if (UnityEditor.Handles.ShouldRenderGizmos())
    {
        context.DrawGizmos(camera, GizmoSubset.PreImageEffects);
        context.DrawGizmos(camera, GizmoSubset.PostImageEffects);
    }

    context.Submit();

代码里面有一个gbuffer,也就是我们后面要用来当gbuffer的空间声明,我们只需要为一个unlit shader加上lightmode为gbuffer的标签,我们就可以看到效果了Untitled picture.png


自定义延迟渲染管线

我们要定义一套延迟渲染的管线,在乐乐的书里也讲这个的基本定义,通俗来说,前向渲染就是一个光一个pass,效果混合,延迟渲染就是两个pass,第一个算可见性并存入一切到gbuffer,第二个算光照。所以gbuffer的规划就需要开始了。这里需要通过MultiRenderTarget实现多组纹理缓冲的使用。下面引用一下知乎大佬的图。

首先简单规划下 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

pbr2.jpg

至于这些格式是什么,目前可以先放放,至少Unity帮我们把绝大多数的事儿已经解决了。然后我们就得去管线的脚本里面,把构造函数中加入gbuffer的声明:四个颜色纹理和一个深度纹理(RT);

 private RenderTexture gdepth;                                               // depth attachment
 private RenderTexture[] gbuffers = new RenderTexture[4];                    // color attachments 
 private RenderTargetIdentifier[] gbufferID = new RenderTargetIdentifier[4]; // tex ID 
 public pbrPipelineInstance()
{
    int width = Screen.width;
    int height = Screen.height;
        // 创建纹理
        gdepth = new RenderTexture(width, height, 24, RenderTextureFormat.Depth, RenderTextureReadWrite.Linear);
        gbuffers[0] = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear);
        gbuffers[1] = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB2101010, RenderTextureReadWrite.Linear);
        gbuffers[2] = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB64, RenderTextureReadWrite.Linear);
        gbuffers[3] = new RenderTexture(width, height, 0, RenderTextureFormat.ARGBFloat, RenderTextureReadWrite.Linear);

        // 给纹理 ID 赋值
        for (int i = 0; i < 4; i++)
            gbufferID[i] = gbuffers[i];

    //renderPipelineAsset = asset;
}

然后就是在ClearRenderTarget之前调用setRendertarget把输出绑定到gbuffer。

  //bind gbuffer rendering
  cmd.SetRenderTarget(gbufferID, gdepth);

  //setglobal
  cmd.SetGlobalTexture("_gdepth", gdepth);
  for (int i = 0; i < 4; i++)
      cmd.SetGlobalTexture("_GT" + i, gbuffers[i]);

建立一个gbuffer的shader并修改成给gbuffer绘制的pass,Unity自带了SV_Target 让用户输入到缓冲里,我们让片元着色器写入一下这几个缓冲,out float4 GT0 : SV_Target0 代表了给 GT0 变量赋值即可实现写入 0 号纹理缓冲。(如果直接:SV_Target就是把返回值直接存到这上面去)。这个地方我把宽度和高度都转成了int,主要原因是不转rendertexture函数可能会报错,它的参数不能是浮点数。

 void frag (v2f i, out float4 GT0 : SV_Target0,
                 out float4 GT1 : SV_Target1,
                 out float4 GT2 : SV_Target2,
                 out float4 GT3 : SV_Target3)

随便放一些颜色到这个rt上去可以检测见过,然后我们还需要设计一个利用gbuffer绘制的pass,首先在instance里面加入以下的声明,并在后续的render中调用,这里我们要把lightpass放在天空和球体之后绘制,不然会有遮挡的情况发生。大概就是通过blit函数实现对摄像机内容的绘制。 public void LightPass(ScriptableRenderContext context, Camera camera) { CommandBuffer cmd = new CommandBuffer(); cmd.name = "lightpass";

    Material mat = new Material(Shader.Find("pbr/lightpass"));
    cmd.Blit(gbufferID[0], BuiltinRenderTextureType.CameraTarget, mat);
    context.ExecuteCommandBuffer(cmd);
}

我们在lightpass中 sampler2D _gdepth; sampler2D _GT0; sampler2D _GT1; sampler2D _GT2; sampler2D _GT3;

然后通过tex显示一下这些图发现可以和之前的pbrshader一样画到屏幕上,那就说明我们成功了。 3.png It work!!有报错说深度图和颜色图的大小不一致,但是可以先不管(我de了很久发现这个bug提示不影响,换成f1之后不报了)然后再render里面设置gbuffer为全局纹理

cmd.SetGlobalTexture("_gdepth",gdepth);
for(inti=0;i<4;i++)cmd.SetGlobalTexture("_GT"+i,gbuffers[i]);

在unity里,rt的实现是通过在context类的commandbuffer里面放入一个渲染目标setrendertarget实现的,如果要混合(实现运动模糊等后处理)就用blit函数:根据shader进行渲染(Copies source texture into destination render texture with a shader.)可以见乐乐书的p273。输出几个通道看看! 4.png

那么在管线基本实现之后,我们就动手编写pbr了。

PBRshading

首先讲一下pbr相关的知识基础,pbr的全称是physical based rendering,是一整套从美术制作到渲染的全流程,目前主要有Metalness工作流和Specular工作流两种。两者都有人在用,不过后者要多一张贴图,当然artifact也少一些,对高光精确的控制是更加接近真实的,下面的参考图来自八猴的文章 5.png 6.png 而更深入来说,我们之前看到的gbuffer的分配其实是基于metalic的,因为它是单通道,合一和roughness一起放,就可以有更多的空间去存别的。分享一个看到的小知识:理论上来说,一个表面的金属度应该是二元的:要么是金属要么不是金属,不能两者皆是。但是,大多数的渲染管线都允许在0.0至1.0之间线性的调配金属度。这主要是由于材质纹理精度不足以描述一个拥有诸如细沙/沙状粒子/刮痕的金属表面。通过对这些小的类非金属粒子/刮痕调整金属度值,我们可以获得非常好看的视觉效果。

首先要确定一个基本原则,光只有两个去向,折射光和反射光,符合1-x关系(吸收那部分也在漫反射了)。接下来,所有的pbr技术都基于微表面理论,learnOpengl给出了它的通俗解释

一个平面越是粗糙,这个平面上的微平面的排列就越混乱。这些微小镜面这样无序取向排列的影响就是,当我们特指镜面光/镜面反射时,入射光线更趋向于向完全不同的方向发散(Scatter)开来,进而产生出分布范围更广泛的镜面反射。而与之相反的是,对于一个光滑的平面,光线大体上会更趋向于向同一个方向反射,造成更小更锐利的反射:

7.png

所以粗糙度就可以计算出某个向量方向和微平面的平均取向方向为一致的概率,对于之前的光照模型来说就是blinn模型里面的那个中间向量的h,高光的计算公式为Cspec = (Clight * mspecular) max(0, n*h)^mgloss8.png 法线(微平面的取向)越是和这个h一致,就越为高光(镜面反射)然后加上一个粗糙度控制整体的高光。同时根据能量守恒,粗糙度也要能够控制高光区域的大小,不仅仅是当时的gloss。
然后就是折射的部分了,一般在不透明物体我们会把它理解为那些散落进去又出来的光,构成了漫反射的颜色。一般是不计算真正的折射部分的,而又一些比如皮肤,大理石等等的为了实现次表面散射主要就是通过利用一些trick比如预积分,或者加入一个背光常量实现,不过这对写pbr暂时帮助不大,就不管了。 对于特殊的成员:金属,由于其导电性,折射光是全部吸收的,所以金属没有漫反射。

PBR原理

10.png 整一个方程其实是一个半球积分,是针对每一个入射方向加和到一起求出w0方向的光。 11.png L是一个辐照度计算的过程,其中Φ代表辐射通量,A是单位面积,w是一个立体角,L计算出的是通过某个无限小的立体角ωi在某个点上的辐射率 12.png

Cook—Torrance反射率方程

然后就提到最重点的渲染方程(Cook—Torrance反射率方程).后续基本都围绕这个式子解释 9.png 三个项 L0表示从 p 点沿着 wo 方向(观察)出射的光的颜色 Li表示从 p 点沿着 wi (的反方向)入射的光的颜色 Fr代表 BRDF,描述了光线从 wi 入射到 p 点再反射到 wo 方向上,还剩下多少能量(衰减和吸收和阻挡) n是P点的微元法向量

在BRDF项里面,分为了漫反射和镜面反射两部分,k是二者的比率因子,满足

float3 k_s = F;
float3 k_d = (1.0 - k_s) * (1.0 - metallic);

前者是漫反射,c就是简单的表面颜色(albedo),DFG分别为法线分布函数,菲涅尔方程和几何函数, 13.png

  • Distrubute法线分布函数 估算在受到表面粗糙度的影响下,取向方向与中间向量一致的微平面的数量。这是用来估算微平面的主要函数。
  • Fresnel菲涅尔方程 这个很熟悉了,就是在不同的
  • Geometry几何函数 平面自阴影的属性(微平面阻挡)

D 法线分布函数

14.png GGX模型是目前引擎中应用最多的关于表面法线的渲染模型,这个在不同的论文中有不同的写法,不管大体上的次数是没有差别的。看了三个方程后最直观的应该是learnopengl上的这个 15.png n就是法线,h是中间向量,a为粗糙度,怎么推导的这里暂时还是超出我能力范围了,但也可以看出来,上下的a2某种程度上是可以对冲的,当a很小的时候,下面的a项会变得很大,所以n的影响会变大,也就是更显著的突变,当a很大的时候(到1)则相反,n取很大范围都可以有相似的NDF值。而次数上来看,a大了则整体变小,也就是变暗。所以从单调性上理解这个函数,一切都开始明晰了起来

F 菲涅尔

Everything is Fresnel的文章想必也看过了,也是目前理解难度最低的等式了(当然这里仅仅是说理解比较简单,对于速学鸡来说,数学永远是天敌,不过幸好目前多数的技术还是不需要完全自己推导)用的是Fresnel-Schlick公式来进行近似的。 16.png

当然这个方程的F0是一个材质常量,一般用0.04可以满足绝大多数的基础反射率需求,但是我们也知道金属的“漫反射”颜色其实都是从镜面反射来,所以直接用折射率计算的黑白的F0需要加上颜色才是金属的Fresnel颜色。btw,也有说h*n项是v*n,但影响不大,就是一些常量的区别。上面提到的这个计算

G 几何函数

17.png 粗糙的表面可能会挡住你的视线,导致某些地方的光会变暗。实现的效果也基本就是不同粗糙度的视觉效果。我们采用的模型是Schlick-GGX模型。 18.png 19.png

先列对于一个光线的情况,这里的k是针对不同光照(直接光照/IBL图像光照)的一个粗糙度重映射值,但都是粗糙度的平方级别的 20.png 但光线的传播分为入射和出射,光线方向和观察方向都需要计算一次这样的遮挡,所以公式就变成了 。 21.png 把这些公式整理出来,我们就获得了PBR光照的全部项,虽然unity已经写好了这些函数,但是我们把刚学的东西火速用上,

//D
float D_GGX_TR(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
vec3 fresnelSchlick(float HdotV, vec3 F0)
{
    float m = clamp(1-HotV,0,1);
    return F0 + (1.0 - F0) * pow(m, 5.0);
}

//G
float GeometrySchlickGGX(float NdotV, float k)
{
    float nom   = NdotV;
    float denom = NdotV * (1.0 - k) + k;

    return nom / denom;
}

float GeometrySmith(vec3 N, vec3 V, vec3 L, float k)
{
    float NdotV = max(dot(N, V), 0.0);
    float NdotL = max(dot(N, L), 0.0);
    float ggx1 = GeometrySchlickGGX(NdotV, k);
    float ggx2 = GeometrySchlickGGX(NdotL, k);

    return ggx1 * ggx2;
}

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 = abs(dot(N, V));
    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);

   float  D = D_GGX_TR(NdotH, alpha);
    float3 F =  fresnelSchlick(HdotV, F0);
    float  G =  GeometrySmith(N,V,L,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;
}

细心的盆友一定发现这个地方NV的点乘被返回了一个abs,这是我在实践中遇到最大的问题,有一个球体的一半变黑了?然后查到知乎有大佬给出了这个bug的解释。

在使用法线贴图和透视摄像机的情况下,法线会出现一些负值的情况,在这里为了物理的准确,并不是把这个负值clamp到0,而是让他偏转回正值。可以使用扭转回摄像机的办法但是太耗了所以用abs近似处理

参考文献

作者: Ayse

2024 © typecho & elise