yukamu 发表于 2022-6-13 11:59

Unity SRP (一)SRP基础与PCSS

(贴个链接先)
相比于其他商业引擎复杂的固定管线(比如UE),Unity提供了高度可编程的渲染管线,具有较为完备的功能,相比于底层的图形API也更易上手,今天我们就来简单了解一下SRP。
相关代码和着色器文件都会较为简略,理解思路为主。
开始之前

在开始之前,简要了解一下即将接触的相关工具与概念。
环境配置


[*] Unity

[*]请安装Unity 2019或更新的版本,LTS最佳。
[*]本文使用的Unity版本为2020.3.0。
[*]方便HDR等操作,提前将颜色空间转换为线性空间。




设置颜色空间


[*] IDE

[*]能正常运行C#脚本即可。
[*]相关shader文件推荐使用VS或者VSCode相关扩展。

基础工具

FrameDebugger,管线验证与调试的必要工具
运行FrameDebugger时,Game窗口的画面会自动暂停。



(仅限Unity 而且不是一直好使,适合简单Debug)

观察基础窗口,可以看到左边是各个DrawCall(下简称DC),可以从名字看出DC的简要信息


右边是DC的详细信息,从上到下包括:

[*]RenderTarget 渲染目标(下简称RT)
[*]RT的选项,可只看某个颜色通道
[*]RT的大小、格式信息
[*]使用的Shader(包括名称、使用的Pass、使用的关键字)
[*]宏控制,包括混合方式,深度测试、深度写入等
[*]一些提示信息
[*]Shader具体的信息,在这里可以看到使用的属性和贴图(Crtl+Click可以看到贴图大概的情况)


你可以用FrameDebugger干什么:

[*]定位具体渲染错误,观察Shader特定属性。
[*]观察DC分布,包括合批是否生效、后处理是否生效等。
Shader的Editor面板

可用于检验Shader是否有语法错误


基础管线

天空盒

首先继承Unity基础的SRP管线,override对应的Render方法
using UnityEngine;
using UnityEngine.Rendering;

public class CustomRenderPipeline : RenderPipeline {
        protected override void Render (ScriptableRenderContext context, Camera[] cameras) {}
}
//创建对应管线文件,推荐分离到另一个代码文件中

public class CustomRenderPipelineAsset : RenderPipelineAsset {
        protected override RenderPipeline CreatePipeline () {
                return new CustomRenderPipeline();
}
通过Render方法可以看到传入了一个SRPContext和相机数组,每个相机利用context的信息,分别会走一遍渲染流程。每个相机的流程相对独立,我们可以创建CameraRenderer类,在PipeLine中逐个调用。
在CameraRender中,我们应设置Camera相关属性(MVP矩阵等),进行相关绘制,最后提交命令。
public void Render (ScriptableRenderContext context, Camera camera) {
        this.context = context;
        this.camera = camera;

        Setup();
        DrawVisibleGeometry();
        Submit();
}
void Setup () {
        context.SetupCameraProperties(camera);
}
void DrawVisibleGeometry () {
        context.DrawSkybox(camera);
}
void Submit () {
        context.Submit();
}
使用我们创建的SRP,正确渲染了天空盒。


CommandBuffer

CommandBuffer(命令队列),CPU将通过CommandBuffer将所需的渲染命令发送给GPU,由CommandBuffer在合适的时候向GPU一次性发送多个命令(或者手动强制队列,提交命令)。
Unity提供了许多常用命令,如:

[*]BeginSample EndSample Profile Sampling 相关命令,帮助FrameDebugger差错
[*]ClearRenderTarget 清除RenderTarget
[*]DrawMesh 使用对应材质绘制Mesh
[*]EnableKeyword DisableKeyword 控制Shader关键字
[*]GetTemporaryRT ReleaseTemporaryRT 创建、删除RT
[*]SetGlobalFloat SetGlobalInt SetGlobalMatrix 设置Shader中的对应数值
看起来像是一个个即时的方法,但它们的实质都是向Cmd中提交命令,如果想立即执行,可调用SRP 的ExecuteCommandBuffer 方法,并清除提交后的Cmd。
我们利用Cmd的相关方法,清理对应RT,设置对应的Profile
void Setup () {
        //先设置相机属性,使清理更加高效
        context.SetupCameraProperties(camera);
        buffer.ClearRenderTarget(true, true, Color.clear);
        buffer.BeginSample(bufferName);
        ExecuteBuffer();
}
void Submit () {
        buffer.EndSample(bufferName);
        ExecuteBuffer();
        context.Submit();
}
void ExecuteBuffer () {
        //提交执行 清理Cmd
        context.ExecuteCommandBuffer(buffer);
        buffer.Clear();
}
正确调用Begin/End Sample后,FrameDebugger中正确显示了对应条目


在Clear里,显示了我们清除了哪些东西,默认会清除所有缓冲,但这不一定是我们想要的。

[*]有时候,我们想保留渲染结果,比如在上面直接画上UI(常见的Overlay模式),也就是保留color,清除深度;
[*]有时候,我们想保存深度信息,比如两个相机分两次绘制了同一场景的不同物体(往往是具体功能需要,比如反射中的动态角色),也就是保留深度,清除Color。
具体如何组合,往往取决于对应相机的CameraClearFlags,那么我们将Clear对应的代码修改为:
CameraClearFlags flags = camera.clearFlags;
buffer.ClearRenderTarget(
        flags <= CameraClearFlags.Depth,
        flags == CameraClearFlags.Color,
        flags == CameraClearFlags.Color ?
        camera.backgroundColor.linear : Color.clear
);
剔除与渲染

在提交渲染之前,CPU端也需要执行一些处理操作(一般叫做应用阶段),其中最重要的就是剔除(虽然已出现了比较现代的GPU剔除等,但作为入门暂时不讨论,使用Unity默认提供的一些API)
void DrawVisibleGeometry () {
        //必要的Setting
        //排序方式,Layer、RenderQueue等
        var sortingSettings = new SortingSettings(camera);
        var drawingSettings = new DrawingSettings(
                        unlitShaderTagId, sortingSettings
        );
        //筛选本次绘制的物体
        var filteringSettings = new FilteringSettings(RenderQueueRange.opaque);
        //绘制
        context.DrawRenderers(
                        cullingResults, ref drawingSettings, ref filteringSettings
        );

        context.DrawSkybox(camera);
        //渲染透明物体
        //修改排序方式
        sortingSettings.criteria = SortingCriteria.CommonTransparent;
        drawingSettings.sortingSettings = sortingSettings;
        //filter 出transparent的物体
        filteringSettings.renderQueueRange = RenderQueueRange.transparent;
        context.DrawRenderers(
                        cullingResults, ref drawingSettings, ref filteringSettings
                );
}
bool Cull () {
        //从相机中获取剔除参数 SRPContext执行剔除
        if (camera.TryGetCullingParameters(out ScriptableCullingParameters p)) {
                cullingResults = context.Cull(ref p);
                return true;
        }
        return false;
}
大概效果:


当然你可以试一下不将二者分开,Filter使用RenderQueueRange.All,可以很明显的看出深度缓冲和绘制顺序都是不合理的。
Editor下的渲染

相信接触过渲染的,都在Shader编译出错时,遇见过神秘的洋紫色材质:


以及Unity在Editor窗口的各种图标与提示:


但这并不是奇迹与魔法,而是写在渲染管线里的。
为了便于整理,可以将CameraRenderer改为**partial** 类,将这些editor里使用的功能塞进去,并用UNITY_EDITOR的宏进行控制。
public void Render (ScriptableRenderContext context, Camera camera) {
        this.context = context;
        this.camera = camera;
        //重新设置buffer Name,方便Debug
        PrepareBuffer();
        //UI Elemnts 会生成新的顶点,应在剔除之前完成
        PrepareForSceneWindow();
        if (!Cull()) {
                return;
        }

        Setup();
        DrawVisibleGeometry();
        //洋紫色
        DrawUnsupportedShaders();
        //一些Icon
        DrawGizmos();
        Submit();
}
DrawCall 与批处理

基础概念


[*]DrawCall
简单定义:给GPU传了一次命令,就叫DrawCall.
为啥要在意DrawCall:GPU算力足够,性能瓶颈往往出现在二者的通信(DC)和数据传输上,合理的减少DC,对性能优化非常重要

[*]批处理
定义:将多个DC的数据统一到一个DC中,就是批处理。
静态批处理

静态批处理历史较长,他并不会直接降低DC,而是给绝对静止的物体做了许多预计算,包括但不仅限于:顶点变换、光照计算等,如果两个物体使用了同样的shareMaterial,不会切换渲染状态,以此降低通信消耗和计算量。
缺点:占用内存过大,过于粗暴的预计算不仅增加了包体,而且无助于DC的降低,在GPU计算速度提高的今天处境尴尬。
动态批处理

在运行时,对于视野中使用同一个材质球的物体,将其顶点信息统一到世界空间下,统一进行运算。

[*]缺点:

[*]要求严苛,包括但不仅限于:

[*]顶点数不能超过900,超过了算不过来。
[*]不能使用多Pass,多Pass两个物体渲染路径和状态并不一定一致。(往往意味着不能给受实时光照的物体合批)
[*]不能用代码动态修改mt。
[*]镜像变换不能合批

[*]额外运算量(往往是由CPU完成顶点合并运算)

[*]优点:包体终于小了,DC降了。
使用:设置GraphicsSettings 即可
GPU Instancing

在使用相同材质球、相同Mesh的情况下,Unity对于正在视野中的符合要求的对象使用Constant Buffer,将其位置、缩放、uv偏移等相关信息保存在里面。
然后从中选取一个对象送入渲染流程,不同的着色器阶段可以从缓存区中直接获取到需要的常量,不用设置多次常量。
缺点:

[*]Constant Buffer每帧都要被重新创建
[*]Constant Buffer不能过大(不得大于64k)
非常适合植被等物体的渲染。
Shader需要做的适配:
//在.shader文件中指定
#pragma multi_compile_instancing
//include 对应文件
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl"

//属性声明
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
        UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)

struct Attributes {
        float3 positionOS : POSITION;
        //加入宏控制
        UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings {
        float4 positionCS : SV_POSITION;
        //加入宏控制
        UNITY_VERTEX_INPUT_INSTANCE_ID
};
Varyings UnlitPassVertex (Attributes input) {
        Varyings output;
        //VS 里设置并传给FS
        UNITY_SETUP_INSTANCE_ID(input);
        UNITY_TRANSFER_INSTANCE_ID(input, output);
        ...
        ...
}
float4 UnlitPassFragment (Varyings input) : SV_TARGET {
        //FS里设置
        UNITY_SETUP_INSTANCE_ID(input);
        return UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
}
SRP批处理

可以视作GPU Instancing升级版,相比于每帧都要重建数据的Instancing,在运行时对于正在视野中的符合要求的所有对象使用“Per Object” GPU BUFFER,将其位置、缩放、uv偏移等相关信息保存在GPU内存中,同时也会使用Constant ****Buffer,将保存材质信息。
缓冲区内有更多的空间。由于数据不再每帧被重新创建,而是动态更新,所以SRP Batcher的本质并不会降低Draw Calls的数量,它只会降低Draw Calls之间的GPU设置成本。


使用:使用对应声明宏,并设置设置GraphicsSettings 中的useScriptableRenderPipelineBatching
//PerObject 的CBUFFER
//命名通过#define UNITY_MATRIX_I_M unity_WorldToObject的方式,方便使用Unity相关库函数
CBUFFER_START(UnityPerDraw)
        float4x4 unity_ObjectToWorld;
        float4x4 unity_WorldToObject;
        float4 unity_LODFade;
        real4 unity_WorldTransformParams;
CBUFFER_END

//PerMaterial 的CBUFFER
CBUFFER_START(UnityPerMaterial)
        float4 _BaseColor;
CBUFFER_END
编写正常,可以在对应Shader的Inspector中看到:


直接光

还是先看看Unity光照的各种属性:



[*]Type:分为Directional、Point、Spot、Area
[*]Color、Intensity:光照颜色、强度
[*]Mode、IndirectMultier:是否使用离线烘焙的光照、以及弹射此时
[*]RealTime Shadows

[*]Strength:阴影强度
[*]Resolution:分辨率
[*]Bias、NormalBias:解决自遮挡
[*]NearPlane:近平面距离

我们先在hlsl文件中定义一下相关数据:
#define MAX_DIRECTIONAL_LIGHT_COUNT 4
#define MAX_OTHER_LIGHT_COUNT 64

CBUFFER_START(_CustomLight)
        int _DirectionalLightCount;//光源数量
        float4 _DirectionalLightColors; //阴影颜色
        float4 _DirectionalLightDirections; //直接光方向
        //float4 _DirectionalLightShadowData;
CBUFFER_END然后在C#端,另起一个Lighting类,传递对应数据:
public void Setup (
        ScriptableRenderContext context,
        CullingResults cullingResults,
        ShadowSettings shadowSettings,
        bool useLightsPerObject
) {
                this.cullingResults = cullingResults;
                buffer.BeginSample(bufferName);
                SetupLights(useLightsPerObject);

                buffer.EndSample(bufferName);
                context.ExecuteCommandBuffer(buffer);
                buffer.Clear();
}
void SetupLights (bool useLightsPerObject) {
        NativeArray<VisibleLight> lights = cullingResults.visibleLights;
        //简写了代码
        for(int i = 0;i < lightCount;i++){
                switch(lights.type){
                        //直接光
                        case LightType.Directional:
                                //方向光上限,往往是4
                                if (dirLightCount < maxDirLightCount) {
                                        SetupDirectionalLight(dirLightCount++, i, ref visibleLight);
                                }
                        ...
                        //其他种类灯光
                        ...
        }
        //遍历完成后统一设置
        buffer.SetGlobalInt(dirLightCountId, dirLightCount);
        if (dirLightCount > 0) {
                buffer.SetGlobalVectorArray(dirLightColorsId, dirLightColors);
                buffer.SetGlobalVectorArray(dirLightDirectionsId, dirLightDirections);
                buffer.SetGlobalVectorArray(dirLightShadowDataId, dirLightShadowData);
        }
}
//先设置运行时的数组
void SetupDirectionalLight (int index, int visibleIndex,ref VisibleLight visibleLight) {
                dirLightColors = visibleLight.finalColor;
                dirLightDirections = -visibleLight.localToWorldMatrix.GetColumn(2);
                dirLightShadowData = shadows.ReserveDirectionalShadows(visibleLight.light, visibleIndex);
}
使用对应的BRDF(此处省略BRDF相关文件),我们可以简单得到如下效果:


阴影

使用最基础的ShadowMap
首先,我们要在运行时创建ShadowMap
if (shadowedDirLightCount > 0) {
                RenderDirectionalShadows();
                }else {
                        //Create Minnn Texture For WebGL 2.0
                        buffer.GetTemporaryRT(
                                dirShadowAtlasId, 1, 1,
                                32, FilterMode.Bilinear, RenderTextureFormat.Shadowmap
                        );
}
然后传递信息:包括但不仅限于:对应的MVP矩阵,CSM相关配置,RealTimeShadows的Bias等设置
void RenderDirectionalShadows () {
                int atlasSize = (int)settings.directional.atlasSize;
                //创建ShadowMap对应的RT
                buffer.GetTemporaryRT(dirShadowAtlasId, atlasSize, atlasSize,
                        32, FilterMode.Bilinear, RenderTextureFormat.Shadowmap);
                //设置RT
                buffer.SetRenderTarget(
                        dirShadowAtlasId,
                        RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store
                );
                //Unity Pancaking Optimization 可以参考下面的说明
                buffer.SetGlobalFloat(shadowPancakingId, 1f);
                buffer.ClearRenderTarget(true, false, Color.clear);
               
                buffer.BeginSample(bufferName);
                ExecuteBuffer();
                //将大RT分给不同光源和Level
                //想搞Tiny一点,不用支持多光源和CSM的可以直接不用Tile 挺麻烦的
                int tiles = shadowedDirLightCount * settings.directional.cascadeCount;
                int split = (tiles <= 1 ? 1 : (tiles <= 4 ? 2 : 4));
                int tileSize = atlasSize / split;
                for (int i = 0; i < shadowedDirLightCount; i++) {
                        RenderDirectionalShadows(i, split, tileSize);
                }
                //设置一些常见属性
                //每个Level的视锥
                buffer.SetGlobalVectorArray(cascadeCullingSpheresId, cascadeCullingSpheres);
                //每个Level的转换矩阵
                buffer.SetGlobalMatrixArray(dirShadowMatricesId, dirShadowMatrices);
                //一些data
                buffer.SetGlobalVectorArray(cascadeDataId, cascadeData);
               
                buffer.EndSample(bufferName);
                ExecuteBuffer();
        }

void RenderDirectionalShadows (int index, int split, int tileSize) {
        ShadowedDirectionalLight light = ShadowedDirectionalLights;
        var shadowSettings =
        new ShadowDrawingSettings(cullingResults, light.visibleLightIndex);
               
        int cascadeCount = settings.directional.cascadeCount;
        int tileOffset = index * cascadeCount;
        Vector3 ratios = settings.directional.CascadeRatios;

        float cullingFactor = Mathf.Max(0f, 0.8f - settings.directional.cascadeFade);
        for (int i = 0; i < cascadeCount; i++) {
                cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(
                        light.visibleLightIndex, i,
                        cascadeCount, ratios, tileSize,
                        light.nearPlaneOffset,
                        out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix,
                        out ShadowSplitData splitData
                );
                splitData.shadowCascadeBlendCullingFactor = cullingFactor;
                shadowSettings.splitData = splitData;
                       
                if (index == 0) {
                        //只是 W预平方 ,也可以在 Shader里完成
                        Vector4 cullingSphere = splitData.cullingSphere;
                        float texelSize = 2f * cullingSphere.w / tileSize;
                        //Normal Bias
                        float filterSize = texelSize * ((float)settings.directional.filterMode + 1f);
                        //Decrease The Radius Of CullingSphere
                        //For PCF Sampling
                        cullingSphere.w -= filterSize;
                        cullingSphere.w *= cullingSphere.w;
                        cascadeCullingSpheres = cullingSphere;
                               
                        cascadeData = new Vector4(
                                1f / cullingSphere.w,
                                filterSize * 1.4142136f);
                        }
                        int tileIndex = tileOffset + i;
                        float tileScale = 1f / split;
                        //VP Maxtrix Modify
                        dirShadowMatrices = ConvertToAtlasMatrix(
                                projectionMatrix * viewMatrix,
                                SetTileViewport(tileIndex, split, tileSize),tileScale
                        );
                        //全自主命名的SRP不推荐使用
                        //实质是Unity默认的API,使用时相关属性必须遵循命名规则
                        buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);
                        buffer.SetGlobalDepthBias(0f, light.slopeScaleBias);
                        ExecuteBuffer();
                        context.DrawShadows(ref shadowSettings);
                        buffer.SetGlobalDepthBias(0f, 0f);
        }
}
关于Unity Pancaking,可参考Unity - Manual: Shadow troubleshooting(相当于自动把近平面推前了,优化存储和一些细小物体的毛刺问题)
运行时已经配置了相关数据,接下来是如何在Shader使用相关数据的问题
通过调用Lit物体的ShadowCaster,调用 context.DrawShadows 写入ShadowMap
在正常着色中使用ShadowMap
//直接采样
float SampleDirectionalShadowAtlas (float3 positionSTS) {
        return SAMPLE_TEXTURE2D_SHADOW(
                _DirectionalShadowAtlas, SHADOW_SAMPLER, positionSTS
        );
}
//包一层PCF
float FilterDirectionalShadow (
        int tileIndex,int cascadeIndex,
        float3 positionSTS,float rotateAngle
) {
        #if defined(DIRECTIONAL_FILTER_SETUP)
                float weights;
                float2 positions;
                float4 size = _ShadowAtlasSize.yyxx;
                //"com.unity.render-pipelines.core/ShaderLibrary/Shadow/ShadowSamplingTent.hlsl"
                //提供的简单PCF包装
                DIRECTIONAL_FILTER_SETUP(size, positionSTS.xy, weights, positions);
                float shadow = 0.0;
                for (int i = 0; i < DIRECTIONAL_FILTER_SAMPLES; i++) {
                        shadow += weights * SampleDirectionalShadowAtlas(float3(positions.xy, positionSTS.z));
                }
                return shadow;
        #else
                return SampleDirectionalShadowAtlas(positionSTS);
        #endif
}
float GetCascadedShadow (
        DirectionalShadowData directional, ShadowData global, Surface surfaceWS)
{
        float3 normalBias = surfaceWS.interpolatedNormal *
                (directional.normalBias * _CascadeData.y);
        float3 positionSTS = mul(
                _DirectionalShadowMatrices,
                float4(surfaceWS.position + normalBias, 1.0)
        ).xyz;

        //WithOut Normal Bias
        float shadow = FilterDirectionalShadow(
                directional.tileIndex, global.cascadeIndex,
                positionSTS,rotateAngle
        );

        //With Normal Bias
        if (global.cascadeBlend < 1.0) {
                normalBias = surfaceWS.interpolatedNormal *
                        (directional.normalBias * _CascadeData.y);
                positionSTS = mul(
                        _DirectionalShadowMatrices,
                        float4(surfaceWS.position + normalBias, 1.0)).xyz;
                shadow = lerp(
                        FilterDirectionalShadow(
                                directional.tileIndex + 1,
                                global.cascadeIndex + 1,
                                positionSTS
                        ), shadow, global.cascadeBlend
                );
        }
        return shadow;
}

float GetDirectionalShadowAttenuation (
        DirectionalShadowData directional, ShadowData global, Surface surfaceWS
) {
        float shadow;
        if (directional.strength * global.strength <= 0.0) {
                ...//纯烘焙
                ...
        }else {
                shadow = GetCascadedShadow(directional, global, surfaceWS);
                shadow = MixBakedAndRealtimeShadows(
                        global, shadow, directional.shadowMaskChannel, directional.strength
                );
        }
        return shadow;
}
PCSS

在实现了基础的PCF后,PCSS基础的实现并不是非常困难
我们先来了解一下PCSS的基础概念:
PCF固然提供了不错的软阴影效果,但固定的Filter范围并不适用于所有情况,比如远处的小石子和近处的人物都适用一个Range时,常常只能保证一方的阴影效果。
为此,我们引入了PCSS,动态调整Filter的范围


PCSS思想也很简单:

[*]先粗测一下覆盖情况
[*]根据遮蔽情况调整Filter范围
[*]和PCF一致,计算软阴影


利用相似三角形,求出Filter的Raidus,d_Receiver是给定点的depth,d_Blocker由第一步的Average求得,w_Light属于光源属性


数学原理并不复杂,但实际加入会发现有许多问题:

[*]最初search的大小怎么确定:

[*]saturate(d_point - d_nearPlane ) /d_point
[*]最好乘以一个缩放系数(因为我在上面把一块RT切分了,没有缩放效果比较差)

[*]w_light怎么确定:实际上,w_Light是光源的大小,但对于我们实现的直接光和点光源,并没有大小这个概念,最好的办法就是暴露一个参数调。
[*]有了w_Penumbra怎么计算对应Fiter的大小:实际查阅了相关资料发现大家的差异很大

[*]Unity之前一个实现:利用w_Penumbra在min_radius和max_radius之间插值

[*]实际使用:可能是我CSM相关配套的没做好,实际效果很差,默认的min_radius也确实有点大了

[*]乘个参数:比较常见,但对于应用了CSM中不同Level的阴影可能有点怪(毕竟不同Level的精度是不一样的,但这个比例却是统一的,反直觉)
[*]根据不同Level的大小(比如之前提到的CascadeCullingSphere大小),缩放一下




精度比较高的PCF



精度一搬的PCSS

效果图(随便调了一下,没做进一步优化)
总结

PCSS效果还是有待优化,基础Forward还是有点简单,另起一个Defer了该
有空一定Orz
参考资料
页: [1]
查看完整版本: Unity SRP (一)SRP基础与PCSS