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 &#34;Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl&#34;
//属性声明
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;
//&#34;com.unity.render-pipelines.core/ShaderLibrary/Shadow/ShadowSamplingTent.hlsl&#34;
//提供的简单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]