LWRP

发布于 18 天前  59 次阅读


本篇讨论LWRP的底层实现
我写作时,使用的是SRP 6.8.0 Release版本,Unity编辑器版本为2019.2.0b7;
学习本篇需要有一定的渲染管线基础,参考前面Shaderlab相关的文章;
源代码参考Unity的官方SRP github;

什么是SRP或者LWRP

ScriptableRenderPipeline是全称,如果谷歌搜索很快就能定位到Unity的SRP github;
如果搜索可编程渲染管线可能会看到一些2016年前的资料,还有固定管线的说法;
所以负责与硬件沟通的编程语言一直存在,可编程和高度可配置是这些语言的发展方向;
而Unity提供的SRP,是一个C#脚本封装版本的可编程渲染管线,本质上是不开源的,高度可配置;
LWRP或者HDRP,相当于Unity在说:你嫌我写的不好,笔给你,你来写;

LWRP做了什么
如果管线实例的Render函数直接return,那么屏幕就是全黑的,没有任何渲染事件发生(这应该是个省电的好方式);
如果要实现在屏幕上绘制任何东西,需要向指定的RenderTexture执行Draw命令,这需要代码demo才能更好的说明;
可以搜索“SRP demo”获取一个github教程,里面示例了3个简单效果,实现了单一颜色、不透明物体、透明物体等;

有哪些可以利用的API
一个最简化的SRP版本,必须包含两个类,分别继承于RenderPipeline和RenderPipelineAsset;
从C#语法上看,带abstract关键字的函数必须override,带virtual关键字的函数可选override;
所以,重点需要关注两个子类的自构(反序列化)方法和Render、CreatePipeline等事件处理方法;
宏观上来看这个SRP,依然属于opp编程的范畴;有一大堆的继承关系,看起代码非常绕(官方的英文注释难以帮助理解);
Render函数中的ScriptableRenderContext实例相当于Unity给我们的唯一接口,所有渲染任务都由context代为执行;

LWRP的opp编程结构怎么理解
实际上是一个Pipeline单例,其他类声明的成员和方法都是Render方法中执行所需的参数;
ForwardRenderer是默认的相机渲染逻辑,我们也可以考虑使用2DRenderer;
继承于ScriptableRendererPass的的众多类,这里的Pass表示流水线的一个阶段;
整体上看来就像一个文件夹,将不同的属性分类整理,实际上都是一起的;
所以读取PipelineAsset,是没有必要的,可以直接代码写死;

为什么ScriptableRenderContext是Struct类型的
引用类型的实例分配在堆上,当对象没有被引用时被垃圾回收器回收;
值类型的实例分配在栈上,当离开其作用域后内存被回收;减少GC负担,增加装箱和拆箱负担;
这里context没有ref或out关键字,作为值类型传递参数,其内部不开源,我们仅考虑使用其实现的方法;

我们能用SRP做点什么
参考ScriptableRenderContext提供给我们的API;
会发现有很多已经定义好了的struct:CullingResults、DrawingSettings、FilteringSettings之类的;
具体细节一概不知,目测需要买Unity的源码才能看到细节;
比如说Cull函数,需要我们提供一个ScriptableCullingParameters参数,这个参数要怎么来呢;
调用camera.TryGetCullingParameters方法,依然看不到细节;
所以,底层的东西Unity已经帮我们做好了,我们只需要填充这些sturct,然后调用DrawRenderers、Submit;
虽然没有细节,但是如果这种细节也需要我们来处理也是很耗费精力的;
我们需要将精力集中在自定义特殊渲染队列、后期处理效果等画面表现上;

为什么要学习LWRP呢
性能:声明自己需要的变量,执行自己需要的逻辑,不浪费性能;
排序:让一个物体(过滤方法:Opaque+Layer)在指定的顺序以指定的shader(包括特殊参数)进行绘制;
深度缓冲:根据深度值重建世界坐标实现特殊效果(水底根据深度焦散、光滑木地板反射)、模板测试

默认的RenderTarget

使用下面的代码填充到Render函数:

var cmd = new CommandBuffer();
cmd.ClearRenderTarget(true, true, Color.red);
renderContext.ExecuteCommandBuffer(cmd);
cmd.Release();
renderContext.Submit();
return;

没有任何的渲染队列,RenderTarget在FrameDebug中可以看到是“No name”;
屏幕被清除为红色;
如果Game视图的分辨率固定,Scale不是1,那么红色区域的宽度会跟随缩放比例进行缩放;
在ClearRenderTarget前添加代码:
cmd.SetRenderTarget(BuiltinRenderTextureType.CameraTarget);
与前面的区别是,现在红色区域能铺满屏幕了;

更换上面的代码为:
cmd.SetRenderTarget(Display.main.colorBuffer, Display.main.depthBuffer);
效果不变,但是不确定内部操作是不是一样的;
继续更换代码为:
cmd.SetRenderTarget(BuiltinRenderTextureType.CameraTarget, BuiltinRenderTextureType.CameraTarget);
效果依然不变,虽然不能证明什么,但是我们可以猜测:
BuiltinRenderTextureType.CameraTarget就是默认的RenderTarget;
我们可以基于这样的猜想做其他操作;

添加一段C#代码在管线外:
Screen.SetResolution(1136, 640, true);
这段代码我用于PC端强制分辨率,但是对于编辑模式下的Game视图来说毫无作用;

更改清除指令为:
cmd.ClearRenderTarget(false, true, Color.red);
在FrameDebuger中可以观察到清除操作没有使用到内置清除shader;
所以,只清除颜色缓冲直接使用指令完成了;清除深度缓冲则使用到了shader,并将深度缓冲中的值全写为0;

以上尝试是想说明:
1.Unity已经帮我们准备好了一个RenderTarget,在编辑模式下这个RenderTarget的尺寸能随着Game视图缩放;
2.cmd.SetRenderTarget有内置的不明操作,我们不能确定底层帮我们做了啥;
这个命令可以分别指定颜色和深度缓冲对应的RenderTexture;
我有一个猜测是RenderTexture.active可能是由这两个缓冲拼凑起来的;
3.我们对默认的RenderTarget不够了解,其名称是“No name”,分辨率为屏幕尺寸,格式为R8G8B8A8_SRGB;
但是我们可以尝试填充Unity的RenderTextureDescriptor结构,其完整描述了一个RenderTexture的所有属性;
4.非SRP直接相关,但是与显示设备有关的API,也应该关注下,实际功能需要验证;

为相机设置渲染参数

foreach (var camera in cameras)
{
   if (!camera.TryGetCullingParameters(out var cullingParameters))
        return;
    CullingResults cullResults = renderContext.Cull(ref cullingParameters);
    renderContext.SetupCameraProperties(camera);

    var cmd = new CommandBuffer();
    cmd.ClearRenderTarget(true, true, Color.black);
    renderContext.ExecuteCommandBuffer(cmd);
    cmd.Release();

    SortingSettings sortingSettings = new SortingSettings(camera) { criteria = SortingCriteria.CommonOpaque };
    DrawingSettings settings = new DrawingSettings(new ShaderTagId("LightweightForward"), sortingSettings);
    FilteringSettings filterSettings = new FilteringSettings(RenderQueueRange.opaque, -1);
    renderContext.DrawRenderers(cullResults, ref settings, ref filterSettings);

    renderContext.DrawSkybox(camera);
    renderContext.Submit();
}
return;

这个例子将SRP demo已经过期的代码改动为2019.2版本能用了,会被渲染的物体条件是:
1.layer在相机的Culling Mask内;
2.Shader的Queue值在2500以内;
3.Shader的标签:"LightMode" = "LightweightForward";

同理,可继续填充参数绘制RenderQueueRange.transparent部分;
在FrameDebuger中可以看到4种标签:
MeshSkinning.SkinOnGPU:因为我使用了带蒙皮的模型,这一步看不到具体细节,只能看到处理的顶点数;
Unnamed command buffer:清除命令的标签,如果不清除会在上一帧基础上覆盖绘制;
RenderLoop.Draw:我们填充好参数后,Unity帮我们提交的drawcall;
Camera.RenderSkybox:绘制相机的天空盒子;

renderContext.SetupCameraProperties(camera)操作声明了逐相机全局shader变量;
这里看不到细节,我们可以取消这个函数,自己手动赋值,但是会很繁琐,需要已经确定的shader;

本次操作并没有声明新的RenderTexture,RenderTarget依然是“No name”;
MeshSkinning.SkinOnGPU应该并不使用某个RenderTarget,RT0那里是灰的;

插队与模板测试

上一部分实现了简单的相机的RenderLoop;
场景中符合过滤条件的物体都会执行对应的drawcall,在颜色缓冲和深度缓冲中留下痕迹;
通常shader中的ZTest默认值为LessEqual,不透明物体遵循深度排序,对比深度缓冲中的深度值;
在不透明物体的自定义shader中加入Stencil测试:
Stencil { Ref 10 Comp Always Pass Replace }
新建一个后处理用shader,也加入Stencil测试:
Stencil { Ref 10 Comp Equal}
接下来我们验证一下默认RenderTarget的模板测试的使用;

在上一节的代码的相机循环外,return前,添加代码:

var cmd2 = new CommandBuffer();
cmd2.DrawMesh(fullscreenTriangle, Matrix4x4.identity, emissionMaterial, 0, 0);
renderContext.ExecuteCommandBuffer(cmd2);
cmd2.Release();
renderContext.Submit();

fullscreenTriangle定义一个三角形,刚好能包裹住屏幕;
emissionMaterial将三个顶点直接转换到屏幕屏幕,并正好包裹住屏幕,可直接返回固定颜色;
最终效果是,绘制了物体的区域都通过了Stencil测试,使用了指定材质的shader;
本例想说明的是:
渲染管线允许单独渲染一个场景中不存在的mesh,这独立于渲染排序;
可以使用Stencil测试;
只要没有设置过RenderTarget,我们依然在对默认RenderTarget进行直接绘制;

附上变量声明:

static Mesh s_FullscreenTriangle;
public static Mesh fullscreenTriangle
{
    get
    {
        if (s_FullscreenTriangle != null)
            return s_FullscreenTriangle;

        s_FullscreenTriangle = new Mesh { name = "Fullscreen Triangle" };

        // Because we have to support older platforms (GLES2/3, DX9 etc) we can't do all of
        // this directly in the vertex shader using vertex ids :(
        s_FullscreenTriangle.SetVertices(new List<Vector3>
                {
                    new Vector3(-1f, -1f, 0f),
                    new Vector3(-1f,  3f, 0f),
                    new Vector3( 3f, -1f, 0f)
                });
        s_FullscreenTriangle.SetIndices(new[] { 0, 1, 2 }, MeshTopology.Triangles, 0, false);
        s_FullscreenTriangle.UploadMeshData(false);

        return s_FullscreenTriangle;
    }
}

static Material s_EmissionMaterial;
public static Material emissionMaterial
{
    get
    {
        if (s_EmissionMaterial != null)
            return s_EmissionMaterial;

        s_EmissionMaterial = new Material(Shader.Find("Sekia/PostProcess/CustomBloom"));
        s_EmissionMaterial.name = "emission";
        return s_EmissionMaterial;
    }
}

附上CustomBloom代码:

Shader "Sekia/PostProcess/Emission"
{
    SubShader
    {
        HLSLINCLUDE
        struct a2v
        {
            float3 vertex : POSITION;
        };

        struct v2f
        {
            float4 vertex : SV_POSITION;
            float2 uv : TEXCOORD0;
        };

        v2f vert(a2v v)
        {
            v2f o = (v2f)0;
            o.vertex = float4(v.vertex.xy, 0.0, 1.0);
            o.uv = (v.vertex.xy + 1.0) * 0.5;
            #if UNITY_UV_STARTS_AT_TOP
            o.uv = o.uv * float2(1.0, -1.0) + float2(0.0, 1.0);
            #endif
            return o;
        }

        half4 frag(v2f i) : SV_Target
        {
            return float4(1,1,1,1);
        }
        ENDHLSL

        Pass
        {
            ZWrite Off Cull Off ZTest Always
            Stencil { Ref 10 Comp Equal}

            HLSLPROGRAM
            #pragma only_renderers d3d11 metal
            #pragma target 3.0
            #pragma vertex vert
            #pragma fragment frag
            ENDHLSL
        }
    }
}

MSAA

上面的步骤实现了简单的绘制,但是锯齿非常严重,几乎没法看;
目前最好的抗锯齿的方法是MSAA:
在MSAA模式下每个像素对应着n个子采样点;
只对像素进行着色计算,将颜色值+深度复制到像素覆盖了的子采样点;
覆盖测试区别于深度测试(Depth)和模板测试(Stencil);没有相关暴露的API;
对于普通着色流程来说:计算三角形覆盖的像素,逐像素着色;
对于MSAA流程来说:计算三角形覆盖的子采样点,逐子采样点深度测试模板测试,逐像素(像素中心位置)着色;
对于built-in管线来说,设置QualitySettings.antiAliasing就会自动帮我们分配MSAA所需的backbuffer;
在SRP模式下则需要自己创建RenderTexture了;
而RenderTextureDescriptor完整描述了一个RenderTexture;
为了探究RenderTexture的使用,我们需要探究RenderTextureDescriptor的属性

LWRP中的cmd操作

想要跟踪Unity在具体时间点有什么创建RT/释放RT的操作很简单;
LWRP中在使用commandbuffer时并没有直接new或者release,而是用了CommandBufferPool.Get;
所以我们可以直接在Get方法内log信息定位事件;
常见的事件:
XXX Camera:标记逐相机渲染任务开始;
Create Camera Texture:逐相机Setup时,CreateCameraRenderTarget;
Clear Render State:逐相机Excute时,ClearRenderState;
Setup Light Constants:逐相机Excute时,SetupLights;
Set RenderTarget:逐相机逐Pass Excute时,为下一个任务SetRenderTarget;
Depth Prepass:
Set RenderTarget:逐相机逐Pass Excute时,为下一个任务SetRenderTarget;
Render Opaques;
Set RenderTarget:逐相机逐Pass Excute时,为下一个任务SetRenderTarget;
Set RenderTarget:逐相机逐Pass Excute时,为下一个任务SetRenderTarget;
Render Transparents;
Set RenderTarget:逐相机逐Pass Excute时,为下一个任务SetRenderTarget;
Rneder PostProcessing Effects;
Release Resources

Create Camera Texture

这段代码执行在ForwardRenderer的CreateCameraRenderTarget,核心操作是cmd.GetTemporaryRT;
这个操作得到的RenderTexture不会被自动清除,而且我们只能通过数字标记来引用他;
cmd.GetTemporaryRT(m_ActiveCameraColorAttachment.id, colorDescriptor, FilterMode.Bilinear);
这里的数字标记来自于Shader.PropertyToID,我们可以同时Log,结果是537:
Debug.Log(m_ActiveCameraColorAttachment.id);
Debug.Log(Shader.PropertyToID("_CameraColorTexture"));
所以这个GetTemporaryRT命令的意思,“可能”是说:
给我一个RenderTexture,它的数字ID是537;
如果不存在537对应的RenderTexture,就根据我提供的RenderTextureDescriptor创建一个;
如果已经存在537对应的RenderTexture,具体的操作就不明了,应当避免这种情况;
需要时只get一次,每一帧结束后Release掉,在新的一帧中根据配置创建新的;
这里的colorDescriptor,我们可以通过它来了解LWRP是如何做判断的:
1.什么情况下会创建中间RenderTexture,目前来看msaa和后期处理都会创建中间RT,而不是绘制到默认RT;
2.如何根据平台、配置设置RenderTexture的各项属性;

这里为了表现出耐心,我就把每一个RenderTexture的参数都描述清楚;
FilterMode.Bilinear:
FilterMode的设置不会影响到RenderTexture的内存分配,作为一个补充参数参数进行声明,影响采样操作返回结果时的算法;
RenderTexture记录了横向X竖向像素点,在采样时输入的值通常都不会在像素中心;所以判断返回值时依赖于特定的算法;
FilterMode.Point表示,返回最近的像素值;
FliterMode.Bilinear表示,返回最近四个像素值的加权平均值;
FliterMode.Trilinear表示,返回加权平均值并在不同的mipmap之间混合;

colorDescriptor.autoGenerateMips
这里为true,是new RenderTextureDescriptor的默认设置,默认true;
要实际使用mipmap,得在shader里面采样对应的级别,一般用不上;

colorDescriptor.bindMS
这里为false,如果开启了msaa的RenderTexture,指定在被采样时的前置操作;
如果设置为true,就可以使用masaa的采样方式进行采样;

colorDescriptor.colorFormat
这里为ARGB32,Unity会根据平台(PC、移动)和用途(颜色、深度、阴影)进行设置;

colorDescriptor.depthBufferBits
这里为24,深度缓冲的位数,24就够用了;
创建相机RT时,还会考虑到是否单独创建深度用RT;LWRP会判断是否创建深度贴图;

colorDescriptor.dimension
这里为Tex2D;

colorDescriptor.enableRandomWrite
这里为false,默认设置;

colorDescriptor.msaaSamples
这里返回我们的msaa设置,比如我开启8X,就返回8;

colorDescriptor.sRGB
这里为true,对应Linear空间,shader输出的颜色值在保存到RT时,会变亮;

将以上内容设置到我们自己的Descriptor,我们就能直接创建一个RT;

RenderTextureDescriptor desc = new RenderTextureDescriptor(camera.pixelWidth, camera.pixelHeight);
desc.autoGenerateMips = false;
desc.bindMS = false;
desc.colorFormat = RenderTextureFormat.ARGB32;
desc.depthBufferBits = 24;
desc.dimension = TextureDimension.Tex2D;
desc.enableRandomWrite = false;
desc.msaaSamples = 8;
desc.sRGB = true;
cmd.GetTemporaryRT(Shader.PropertyToID("_CameraColorTexture"), desc, FilterMode.Bilinear);
cmd.SetRenderTarget(Shader.PropertyToID("_CameraColorTexture"));
cmd.ClearRenderTarget(true, true, Color.black);

如果我们将上面的代码插入到前面几节中的Render方法里面,可以观察到:
我们的确创建了一个名为_CameraColorTexture的RT;
开启了msaa时,会自动出现一个_CameraColorTexture的ResolveAA步骤;
后续的Opaque队列没有被绘制到_CameraColorTexture;这里需要继续观察代码;

Clear Render State

这段代码执行于逐相机Execute的开始;
内容为Disable掉光照和阴影相关的关键子;

Setup Light Constants

这段代码执行于逐相机Execute的开始阶段,SetupLights;
和context.SetupCameraProperties(camera, stereoEnabled);在一起
作用同样为声明逐相机渲染所需的光照相关的shader参数、开启/关闭关键字;

第一次Set RenderTarget

第一个被执行的Pass是DepthOnlyPass;对应代码段落为ExecuteRenderPass;
每一个renderPass的子实例都会执行Configure和Execute等操作;
这里跟踪cmd慢慢了解Pass的实例行为;

DepthOnlyPass.Configure
对于每个Pass实例来说,在执行Configure之前,还有实例化和Setup操作;
实例化和Setup设置了Pass所需的变量,但是不涉及cmd我们并没有太关注;
在阅读Configure代码的时候就得把遗漏掉的这部分补上来;

depthAttachmentHandle.id
在Setup中传入的参数,数值为67,对应字符串为_CameraDepthTexture;
descriptor
也是Setup中传入的参数,继承了全部参数,但是修改颜色格式为Depth,深度位数32;
如果我们想要创建深度用的RT,应该参考这个设置;
FilterMode.Point
RT的输出算法,对于分辨率足够的RT不是很核心的设置;
有与cmd不直接相关的代码,这些计算是为了后续准备的,关心价值略低;

m_CameraColorTarget
这个值为ID为537的_CameraColorTexture,在Setup阶段被赋值;
m_CameraDepthTarget
这个值为ID为-1,BuiltinRenderTextureType.CameraTarget;
每个Pass都可以用自己的目标设置覆盖默认设置,如果不覆盖则使用默认设置;
对于DepthOnlyPass来说,声明深度用RT作为颜色输出,但是没直接SetRenderTarget;

对于第一个渲染到默认颜色RT的Pass,会清除一次并设置RenderTarget;
当颜色或深度目标出现变更时,根据Pass设置进行清除并设置RenderTarget;
到这里,Set RenderTarget这个标签的任务完毕了;

Depth Prepass

Depth Prepass和DepthOnlyPass是一回事;
Depth Prepass是DepthOnlyPass.Execute方法中cmd的标签名;
在代码中可以直接看到,使用的是context.DrawRenderers命令;
这个命令的绘制对象是全部Opaque队列的物体,默认LayerMask为-1,默认渲染全部;
使用了指定的Pass,名称为DepthOnly,需要有阴影的物体都需要使用包含这个Pass的shader;
FilteringSettings:指定了Queue范围和LayerMask范围;
DrawingSettings:指定了物体的渲染顺序、主光源、逐物体数据、重写绘制用的Pass;
drawSettings.perObjectData = PerObjectData.None;
这里将逐物体数据重写了,表示不考虑逐物体的光照、反射等数据;

第二次Set RenderTarget

对应的Pass为DrawObjectsPass;
这个Pass没有重写Configure,所以绘制到默认RT,并触发m_FirstCameraRenderPassExecuted;
也就是绘制前清理一次,直接Execute;

Render Opaques

使用context.DrawRenderers命令绘制不透明物体;
与前面的不同的是,这次的DrawingSettings包含多个ShaderTagId,和RenderStateBlock;
多个ShaderTagId的处理方式是使用settings.SetShaderPassName方法填充;
RenderStateBlock中包含了默认渲染设置中的Stencil设置,能提供一个Pass范围的Stencil测试;

第三次Set RenderTarget

对应的Pass为DrawSkyboxPass,这个Pass使用默认RT,且直接在上下文中完成;

第四次Set RenderTarget

对应的Pass为DrawObjectsPass;

Render Transparents

这次绘制的是透明物体,与不透明的Pass实例在同一阶段实例化,有相同的drawSettings,但是过滤设置不同;

Set RenderTarget

对应的Pass为PostProcessPass;
作为管线的最后几步来说,需要判断当前Pass是否是最后一个Pass,并将颜色复制到默认RT中完成渲染;
PostProcessPass的Setup操作参考ForwardRenderer的Setup最后部分;
如果没有开启后处理,这使用一个m_FinalBlitPass代替;其参数是默认的RT描述和默认颜色RT;

这里我们可以手动删除掉后处理的代码,直接添加FinalPass:
m_FinalBlitPass.Setup(cameraTargetDescriptor, m_ActiveCameraColorAttachment);
EnqueuePass(m_FinalBlitPass);
在FrameDebugger中,我们可以发现最后有一个drawcall,其使用的shader是Hidden/BlitCopy;
当我们执行cmd.Blit(source,target)时,就会使用内置的这个shader;
这个文件可以在Unity下载-shader资源里面找到,文件名Internal-BlitCopy.shader;
source RT将会作为贴图被采样,target RT得到类似与复制颜色的效果;
cmd.Blit命令操作的目标是4个顶点,如果这4个顶点能正确投射到屏幕上,需要特定的矩阵;具体计算细节不明;
我们可以用自己写的mesh来完成类似操作;

m_FinalBlitPass.Execute可以简单看作为:

cmd.SetRenderTarget(BuiltinRenderTextureType.CameraTarget, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store);
cmd.ClearRenderTarget(true, true, Color.black);
cmd.Blit(m_Source.Identifier(), BuiltinRenderTextureType.CameraTarget);

Rneder PostProcessing Effects;

当没有AfterRendering类型的Pass时,如果开启了后处理,那么后处理是最后的Pass;
m_Source为默认RT,_CameraColorTexture;
m_Destination为FrameBuffer,No name;
颜色和深度渲染目标被设置为BuiltinRenderTextureType.CameraTarget;

Release Resources


关注成长,注重因果。