Unity SRP学习笔记(二)

Unity SRP学习笔记(二)

主要参考:

https://catlikecoding.com/unity/tutorials/custom-srp/

https://docs.unity.cn/cn/2022.3/ScriptReference/index.html

中文教程部分参考(可选):

https://tuncle.blog/custom_render_pipeline/index.html

https://edu.uwa4d.com/lesson-detail/282/1308/0(依照Unity 2019版的内容翻译,不太适用于Unity 2022)

本文主要是,在参考以上内容学习的过程中,对一些基于已有内容但还是不太能理解的部分进行了一些额外的补充。

.Shader使用HLGL

当然也可以用GLSL在.Shader中写着色器,不过还是首推HLSL,毕竟绝大部分游戏都是在Windows平台下运行的。而且Shader中写的HLSL也不会直接执行,而是会根据目标平台编译成指定图形API。

SRP batcher

https://docs.unity.cn/cn/2019.4/Manual/SRPBatcher.html

初见SRPBatcher,了解到SPRBatcher可以通过合批优化渲染过程中CPU向GPU的数据发送环节。但也就仅限于此了,这种半懂不懂的感觉实在让人难受,于是便计划在实际项目中更深入的认识一下这到底是个什么玩意。

**主要有以下几个问题:**什么是通俗意义上的DrawCall?Unity渲染管线在最原始的情况下会如何进行渲染?经常出现在DrawCall优化中的方法------静态批处理/动态批处理/GPU Instancing都是什么?在Unity中实现后和原来相比到底什么区别?最后,再重新审视一下什么是SPR Batcher?

什么是通俗意义上的DrawCall?

你要写SRP batcher,就不能只写SRP batcher

参考了这篇文章DrawCall,Batches,SetPass calls是什么?,DrawCall就是一个CPU向GPU发出的渲染命令,同时告诉GPU我要渲染哪些数据。渲染管线进行一次渲染的步骤为:1)设置一个Shader为当前渲染状态(设置顶点着色器和片元着色器)。2)传递着色器参数,包括各种变换的矩阵(MVP),自定义的各类变量,以及纹理等。3)调用DrawCall,向GPU发出渲染命令。

文章中描述的一次渲染的流程和OpenGL完全对应的上🤗。

在OpenGL中,while (!glfwWindowShouldClose(window))所包围的部分就是完整的一帧渲染,而通常渲染一帧需要多次调用glDrawElements,glDrawElements就可以看成是DrawCall命令。进行一次glDrawElements,我们需要 1)用glUseProgram设置一个program为当前渲染环境(可选,如果不需要切换Shader就不需要再次设置)。2)设置着色器中Uniform的值,以及绑定VAO,设置layout,绑定纹理。3)调用画图函数(如glDrawElements)渲染目标,函数中指明了要渲染哪些数据。

了解了渲染管线基本步骤和DrawCall是什么,就该进到Unity里了。

为了进行后面的实验,需要能够控制渲染管线是否使用静态批处理/动态批处理/GPU Instancing/SPRBatcher。

Static Batching

我用的Unity版本是2022.3.45f1c1,平台为Windows10,静态批处理可以直接在ProjectSetting中设置。

Dynamic Batching/GPU Instancing

Dynamic Batching和GPU Instancing在SRP中,都需要在C#中手动设置,在URP中可以直接在内置的渲染管线中设置Dynamic Batching(该选项默认隐藏,需要开启全部可见),HDRP不支持Dynamic Batching。

通过对象初始化器{enableDynamicBatching = useDynamicBatching, enableInstancing = useGPUInstancing}设置m_Flags的值。

csharp 复制代码
DrawingSettings drawingOpaqueSettings = new DrawingSettings(unlitShaderTagId, sortingOpaqueSettings) {enableDynamicBatching = useDynamicBatching, enableInstancing = useGPUInstancing };

这是因为Unity中通过变量m_Flags来控制Dynamic Batching/GPU Instancing的启用,m_Flags是一个枚举类型,其默认值为DrawRendererFlags.EnableInstancing。

csharp 复制代码
internal enum DrawRendererFlags
{
    None = 0,
    EnableDynamicBatching = 1,
    EnableInstancing = 2
}

需要像URP一样在渲染管线中控制Dynamic Batching/GPU Instancing,需要在CustomRenderPipelineAsset中创建两个bool型变量并序列化,然后赋值到DrawingSettings中即可。具体实现在DrawCall后半章有。

疑问🤔:对已经通过某个变量完成实例化的类,在inspector中修改该变量为什么还能影响到已经完成实例化的类?

要么是检测如果有实例使用过该变量构造,则重新创建实例。

要么是Unity有什么特殊的办法能够修改readonly的变量。

csharp 复制代码
public class CustomRenderPipelineAsset : RenderPipelineAsset
{
    [SerializeField]
    bool useDynamicBatching = true, useGPUInstancing = true, useSRPBatcher = true;
    protected override RenderPipeline CreatePipeline()
    {
        return new CustomRenderPipeline(useDynamicBatching, useGPUInstancing, useSRPBatcher);
    }
}
SRP Batcher

同样创建一个bool型变量并序列化,但不需要在DrawingSettings中设置,而只需要在CustomRenderPipeline.cs中设置Render函数的GraphicsSettings.useScriptableRenderPipelineBatching即可。

同时还需要提前将变量保存到常量缓冲区中:常量缓冲区介绍

常量缓冲区也需要字节对齐:cbuffer布局介绍,在Unity中如果不手动对齐的话,也能自动对齐。

#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"可能会提示cannot open source file ,说是历史遗留问题,除了写代码不太方便外不影响编译。
In Unity2018 and before, the editor feature ShaderIncludePath was used to configure the relative root of the shader, which is now obsolete.The current method is that #include "Packages//",ShaderLab will parse and configure by itself when linking, but VS does not recognize this writing method and still links according to the default relative path of HLSL files, so an error will be reported, but compilation will not be affected.

以下代码会无法正常使用cbuffer,Shader文件会显示buildin property offset in cbuffer overlap other stages(UnityPerDraw)

csharp 复制代码
CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld;
CBUFFER_END

UnityPerDraw必须定义特定的值组,cbuffer如下,这里的组名可以理解为更新方式,UnityPerDraw表示每次DrawCall更新,而unity_ObjectToWorld在不同DrawCall(即不同物体)时会改变,所以属于PerDraw。而相机的UnityPerFrame在每一帧内都是不变的,所以是PerFrame,不同材质对同一个Shader有不同的参数,即PerMaterial。在使用时Unity会自动检查所以不能混用,

csharp 复制代码
CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;
float4 unity_LODFade;
real4 unity_WorldTransformParams;
CBUFFER_END

CBUFFER_START(UnityPerFrame)
float4x4 unity_MatrixVP;
CBUFFER_END

CBUFFER_START(UnityPerMaterial)
float4 _BaseColor;
CBUFFER_END

In this context, "update" refers to the process of initializing a constant buffer with data that will remain constant throughout the execution of the shader programs that access it. 基于上述内容理解,the execution of the shader programs可以理解为一次DrawCall的过程中cbuffer内的值会保持不变。

至此,在渲染管线中实现了对合批方法的控制。接下来用RenderDoc和Frame Debugger进行实验。

Unity渲染管线在最原始的情况下会如何进行渲染?

场景,40个物体(均使用默认网格),黄色为内置Shader Unlit/Color,绿色和红色为自定义Shader,仅材质参数BaseColor不同。

无合批优化情况下(RenderDoc能捕获到的内容)的Main Camera:

1)RSSetViewports 设置视口大小,ClearDepthStencilView 清除模板和深度缓冲。

2)绘制第一个物体,设置OM阶段混合状态,设置IA阶段顶点缓冲,布局及索引缓冲,设置OM阶段深度和模板缓冲状态,设置RS阶段光栅状态,设置VS和PS的着色器,UnityPerFrame,UnityPerDraw映射到CPU内存空间,修改后释放绑定到VS,UnityPerMaterial同样操作,绑定到PS,DrawIndexed调用绘制。

具体设置的情况在Pipeline State的OM阶段中可知。

cbuffer在Pipeline State的VS阶段和PS阶段的ConstantBuffers栏可见,需要HLSL中添加#pragma enable_d3d11_debug_symbols(提供有关着色器内部状态的附加信息),否则Slot只能看到cbuffer0,cbuffer1...

3)绘制下一个物体,结论为如果 a.同材质,无论是否使用同一个网格,都重新设置顶点,索引缓冲,修改UnityPerDraw。b.同Shader,不同材质,重新设置顶点,索引缓冲,修改UnityPerDraw和UnityPerMaterial。c.不同Shader,和第 2)步一样,所有内容重新设置。

上述是仅使用cbuffer情况下的渲染管线,如果把cbuffer去掉呢O.o。实际上如果去掉显式声明的cbuffer,Unity也会自动把所有的着色器参数放到为$Globals的常量缓冲区中。但是这样的话,因为每一次DrawCall都需要更新unity_ObjectToWorld的值,所以需要对整个Globals常量缓冲区进行映射,不好不好。

静态批处理/动态批处理/GPU Instancing

Static batching

静态批处理是一种绘制调用批处理方法,它将不会移动的网格组合在一起,以减少绘制调用。 它将组合网格转换到世界空间,并为它们建立一个共享顶点和索引缓冲区。 然后,对于可见的网格,Unity 会执行一系列简单的绘制调用,每次调用之间几乎不会改变状态。 静态批处理并不会减少绘制调用的次数,反而会减少它们之间渲染状态变化的次数。
Static batching会将不会移动的网格合并在一起(最主要的作用!) ,需要静态批处理的物体可在Inspector中设置为Stratic。
注意:Static Batching只有在Game窗口运行时才会生效!!!同时,因为是在运行模式下,默认的Clear flag:Skybox不会清除上一帧的颜色缓冲,虽然在运行时没有问题,但是会不便于观察调试,所以修改了一下让Skybox也清除颜色缓冲。

虽然文档说不会减少DrawCall,但是在RenderDoc中看DrawIndexed的次数是会减少的。Static batching只对同材质的物体渲染能有效提升(可不同Mesh)。如果材质不同,Mesh依然会合并且共用一个顶点缓冲区,但依然需要设置顶点缓冲,这样相对于不设置Static batching反而增加了每次DrawCall设置的顶点缓冲大小。

对于组合后的网格能否一次DrawCall绘制还得看是否在索引缓冲区上连续,如果不连续,即使是同材质的物体也需要多次DrawCall。但是不连续也只需要调用一次DrawIndexed,无需再次设置顶点缓冲和索引缓冲,渲染速度也是会更快的。

Dynamic batching

动态批处理是一种绘制调用批处理方法,可批处理移动的游戏对象以减少绘制调用。 动态批处理在网格和 Unity 在运行时动态生成的几何体(如粒子系统)之间的工作方式不同。 有关网格和动态几何体之间内部差异的信息,请参阅网格的动态批处理和动态生成几何体的动态批处理。 注:网格的动态批处理是为了优化老式低端设备的性能而设计的。 在现代消费级硬件上,动态批处理在 CPU 上的工作可能大于绘制调用的开销。 这会对性能产生负面影响。 更多信息,请参阅网格的动态批处理。

Dynamic batching会在每一帧动态计算哪些对象可以合批,哪些对象需要合批,针对需要合批的对象还需要在CPU中做模型变换(转到世界坐标下),这个是要CPU开销的。如果这个开销比不合批时多出来的DrawCall的开销还要大,就成负优化了(所以这种方法现在基本都不用了)。所以尽量还是用Static Batching吧,Static Batching虽然也要将合批对象在CPU中变换到世界坐标下组合,但是是在Build阶段实现的,而不是在运行时每一帧场景绘制之前。

GPU Instancing

GPU 实例化是一种绘制调用优化方法,可在一次绘制调用中渲染具有相同材质的多个网格副本。 每个网格副本称为一个实例。 这对于绘制场景中多次出现的物体非常有用,例如树木或灌木丛。 GPU 实例化可以在同一个绘制调用中渲染相同的网格。 为了增加变化和减少重复,每个实例可以有不同的属性,如颜色或比例。 渲染多个实例的绘制调用会在帧调试器中显示为 "渲染网格(实例化)"。

**用于优化相同材质,相同网格的物体渲染速度。**可以理解为,在所有相同材质,相同网格的物体中取其中一个实例,对于其他物体将会变化的部分以数组的方式存储在GPU常量缓冲区中,每个物体都有一个唯一InstanceID,通过这个ID就可以索引其数据在数组中的数据。

例如,假如每个实例在世界坐标下的位置(即模型变换矩阵)都不一样,那么GPU中就会有一个常量缓冲区(名为PerDraw0)用来存储其模型变换矩阵数组。启用GPU Instancing时,顶点着色器中传入的数据(即Attributes)带有UNITY_VERTEX_INPUT_INSTANCE_ID,通过UNITY_SETUP_INSTANCE_ID(input)设置InstanceID后GetObjectToWorldMatrix()就能获取对应实例的模型变换矩阵(这个部分在UnityInstancing.hlsl中实现,当然如果想变的麻烦的话也可以自己实现。比如这里的_BaseColor就是自己实现的,可以对不同实例实现不同颜色参数)。

PS:使用_BaseColor时,如果想改颜色需要用MaterialPropertyBlock(教程默认用这个,可能不会遇到这种问题),而不是直接改Material。因为直接改Material会创建新的材质,而MaterialPropertyBlock是重写材质,还是原来的材质所以符合使用相同材质的条件。

csharp 复制代码
//.hlsl
//避免多次include导致重定义问题
#ifndef CUSTOM_UNLIT_SHADER

#define CUSTOM_UNLIT_SHADER


//包含了对cbuffer的宏定义替换
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"

#define UNITY_MATRIX_M unity_ObjectToWorld;

//包含了对GPU实例所需的很多内容,可以简单的使用GPU Instancing
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl"

CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;
float4 unity_LODFade;
real4 unity_WorldTransformParams;
CBUFFER_END

float4x4 GetObjectToWorldMatrix()
{
    return UNITY_MATRIX_M;
}

CBUFFER_START(UnityPerFrame)
float4x4 unity_MatrixVP;
CBUFFER_END

UNITY_INSTANCING_BUFFER_START(BaseColorCB)  //可随意命名,用于在UNITY_ACCESS_INSTANCED_PROP中使用
    UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_INSTANCING_BUFFER_END(BaseColorCB)

struct Attributes
{
    UNITY_VERTEX_INPUT_INSTANCE_ID
    float3 positionOS : POSITION;
};

struct Varyings
{
    UNITY_VERTEX_INPUT_INSTANCE_ID
    float4 positionCS : SV_POSITION;
};

Varyings UnlitPassVertex(Attributes input)
{
    Varyings output;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, output); //因为这里的片元着色器中有根据InstanceID设置的变量所以需要传递InstanceID,如果没有可以去掉这部分
    output.positionCS = mul(unity_MatrixVP, mul(GetObjectToWorldMatrix(), float4(input.positionOS, 1.0)));
    return output;
}

float4 UnlitPassFragment(Varyings input) : SV_TARGET
{
    UNITY_SETUP_INSTANCE_ID(input);
    return UNITY_ACCESS_INSTANCED_PROP(BaseColorCB, _BaseColor); //根据InstanceID在名为UnityPerMaterial的常量缓冲区中的_BaseColor数组内知道当前实例的_BaseColor
}

#endif

综上所述,GPU Instancing其实就是把使用同一个Mesh和同一个材质的所有对象会变化的部分以数组的形式存储在常量缓冲区中,再通过InstanceID进行索引。同一个Mesh保证传入的顶点坐标是通用的(即在每个实例渲染中,Attributes的positionOS是通用的),同一个材质大概是确保无需切换渲染状态吧。所以GPU Instancing可以规避合并Mesh导致的内存与性能上升的问题。例如对于大量的树木或者杂草,使用GPU Instancing不会像Static Batching会导致生成一个巨大的顶点缓冲和索引缓冲,只需将模型变换矩阵以数组的形式加载到常量缓冲区中即可。

同样,常量缓冲区是有大小限制的(64kb),Unity中也限制了UNITY_MAX_INSTANCE_COUNT为500,最多一次DrawCall支持500个实例。
【Unity笔记】ShaderLab与其底层原理浅谈

SRP Batcher

Unity SRP Batcher的工作原理
关于静态批处理/动态批处理/GPU Instancing /SRP Batcher的详细剖析

内容可以参照这些文章👆,相比于GPU Instancing,SPR Batcher不会减少DrawCall,但条件更加宽松,只要是同一个Shader即可。

**数据提前加载到GPU:**从RenderDoc中看,所有需要进行渲染的对象的各种信息已经提前保存在GPU缓冲区中(例如顶点信息【不过就算不使用SRP Batcher,默认网格的顶点数据也会在初始化的时候加载到GPU】,模型变换矩阵,材质参数等)了,在DrawCall的准备阶段只需要进行绑定。即便DrawCall没有减少,但是由于每次DrawCall都只需要绑定资源,同时DrawCall本身也只是个绘制调用,所以每次DrawCall的时间大大降低了。

**数据不再每帧被重新创建:**这部分在RenderDoc中没能找到佐证的部分,可能是我用的方法有问题。

❗使用MaterialPropertyBlock重写的材质无法被SRP Bathcer合批处理。

unity/tutorials/custom-srp/draw-calls

简单看下结果,使用GPU Instancing绘制大量网格,只需要3个Batches即可绘制完毕。

不使用GPU Instancing,需要501次Batches(500次绘制球,一次绘制天空盒)。

cutoff / blend

❗记得在新创建的材质中勾选Enable GPU Instancing。

相关推荐
云和数据.ChenGuang3 分钟前
《XML》教案 第1章 学习XML基础
xml·java·学习
今天你比昨天博学了吗18 分钟前
CFD POST导出动画
学习
γ..19 分钟前
基于MATLAB的图像增强
开发语言·深度学习·神经网络·学习·机器学习·matlab·音视频
小王爱吃月亮糖29 分钟前
C++进阶-1-单继承、多继承、虚继承
开发语言·c++·笔记·学习·visual studio
YY-nb44 分钟前
Unity Apple Vision Pro 开发教程:物体识别跟踪
unity·游戏引擎·apple vision pro
Cool-浩1 小时前
Unity 开发Apple Vision Pro物体识别追踪ObjectTracking
unity·ar·apple vision pro·mr·物体识别·vision pro教程·objecttracking
zxguan1 小时前
Springboot 学习 之 logback-spring.xml 日志压缩 .tmp 临时文件问题
spring boot·学习·spring
纪伊路上盛名在1 小时前
爬虫1:uniprot蛋白质序列数据+canvas图片
数据库·学习·知识图谱·学习方法
小王爱吃月亮糖1 小时前
补充--C++的项目结构和管理
数据结构·c++·笔记·学习
PP东7 小时前
ES6学习Generator 函数(生成器)(八)
javascript·学习·es6