编写编辑器脚本
-
创建基本的管线脚本
using UnityEngine; using UnityEngine.Rendering; // RenderPipeline:定义一系列描述 Unity 如何渲染帧的命令和设置。 public class SRenderPipeline : RenderPipeline { /// <summary> /// 定义此渲染管线的自定义渲染 /// </summary> /// <param name="context">定义自定义渲染管线使用的状态和绘制命令</param> /// <param name="cameras">所有的相机</param> protected override void Render(ScriptableRenderContext context, Camera[] cameras) { } }
-
创建右键指令,用于在Assets目录下中创建管线
using UnityEngine; using UnityEngine.Rendering; [CreateAssetMenu] public class SRenderPipelineAsset : RenderPipelineAsset { protected override RenderPipeline CreatePipeline() { return new SRenderPipeline(); } }
两个脚本创建完后,我们就可以在Assets目录下创建我们自己的渲染管线了
这时候我们将自己创建的管线赋值给Graphics中会发现,场景一片黑,什么都无法显示
接下来我们需要在SRenderPipeline的Render方法中添加代码,让画面变得正常
无光照渲染
1.绘制天空盒
Render方法有两个参数,ScriptableRenderContext定义了渲染管线要使用的所有状态和绘制命令,cameras中包含了游戏中的所有摄像机(UI与场景)。
接下来我们遍历所有相机,将特定的全局着色器变量写入相机中,然后将天空盒也绘制到摄像机中,最后调用调度命令,提交给渲染循环执行。
for (int i = 0; i < cameras.Length; i++)
{
Camera camera = cameras[i];
// 调度特定于摄像机的全局着色器变量的设置
context.SetupCameraProperties(camera);
// 调度天空盒的绘制
context.DrawSkybox(camera);
// 将所有调度命令都提交给渲染循环来执行
context.Submit();
}
这时候,场景中绘制出了天空盒,但其他物体依旧没有显现
2.添加绘制不透明无光物体
要想绘制一个无光物体,需要划分三步:裁剪、绘制、过滤,最后在调用绘制命令对物体进行绘制
-
裁剪
裁剪是指将相机视锥体外多余的场景物体进行裁剪,因为这些物体是我们不希望由CUP传递给GPU的,这回带来大量的Drawcall。在该过程中,我们运用到一个对象ScriptableCullingParameters,该对象定义了一个裁剪参数,可对裁剪过程进行各种设置(例如:相机视锥类型等)。一般来说我们都不会从头构建这个参数列表,而是从某个相机中取出一份参数的拷贝,然后对其进行修改。
然后我们调用ScriptableRenderContext.Cull方法并传入定义的裁剪参数,就可以得到裁剪的结果CullingResults
-
绘制
绘制部分我们需要考虑这么几个问题
- 使用哪一个Pass来绘制物体?
- 以什么样的顺序来绘制物体?
- 是否需要使用GPU-instancing来绘制物体(Batching)?
- 对每一个物体需要提供哪些渲染参数?
首先我们需要告诉管线使用哪一个Pass来绘制物体。显然,我们需要使用着色器(shader)来绘制物体,在着色器中我们需要为Pass设置渲染路径(Lightmode),不同的渲染路径下,所处理的光照参数都有所不同。例如:Always代表了不管使用哪种渲染路径,该Pass都会被渲染,但是不会计算任何光照;ForWardBase用于前向渲染,该Pass会计算环境光,最重要的平行光、逐顶点/SH光源和Lightmape等。与其说为渲染管线使用哪种Pass,不如说是给该渲染管线的渲染路径取名,只用在Shader中设置了同样名字的渲染路径才会被该渲染管线所应用。
绘制顺序指的是一系列相同类型的物体应该以怎样的顺序绘制在屏幕上。幸运的是,我们不需要亲自对物体进行排序,只需要指定一个SortFlags,就可以让Unity在绘制之前帮我们排好序。当然,在绘制之前排序需要一定的性能开销,因此对于固体这种绘制顺序不影响的情况,可以简单指定None来关闭排序。
在设置绘制参数时我们使用的对象是:DrawingSettings
-
过滤
在标准的Unity渲染管线中,使用渲染队列来表示物体渲染的先后顺序:排在渲染队列越前面(序号越小)的物体越先被渲染。渲染队列的序号并不是随机指定的,而是根据物体的材质特性(Shader里的RenderType类型)来指定,每一个类型的材质都有其自己的序号分配区间。
但是在可编程渲染管线中,物体的渲染顺序完全取决于我们怎么编写管线,因此渲染队列不再是用于顺序渲染,而仅仅是用于区分不同的渲染类型。在FilteringSettings中可以设置我们需要关注的类型,如设置为RenderQueueRange.opaque就告诉Unity,我们仅渲染所有Queue为opaque的材质的物体。也可以使用UnityEngine.Rendering.RenderQueue中的数值来单独设置一个RenderQueueRange的最小值和最大值,从而设置一个渲染范围。
除了使用渲染队列以外,我们还可以使用Unity的层(Layer)来进行过滤,即只指定在特定几个层里的物体才能被渲染。
最后我们调用DrawRenderers方法去绘制对象。
下面展示了完整代码
using UnityEngine;
using UnityEngine.Rendering;
// RenderPipeline:定义一系列描述 Unity 如何渲染帧的命令和设置。
public class SRenderPipeline : RenderPipeline
{
/// <summary>
/// 定义此渲染管线的自定义渲染
/// </summary>
/// <param name="context">定义自定义渲染管线使用的状态和绘制命令</param>
/// <param name="cameras">所有的相机</param>
protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
for (int i = 0; i < cameras.Length; i++)
{
Camera camera = cameras[i];
// 调度特定于摄像机的全局着色器变量的设置
context.SetupCameraProperties(camera);
// 调度天空盒的绘制
context.DrawSkybox(camera);
/// 裁剪部分
// 自定义一个裁剪参数,cullParam类里有很多可以设置的东西。
ScriptableCullingParameters parameters = new ScriptableCullingParameters();
// 填充来自摄像机的裁剪参数
camera.TryGetCullingParameters(out parameters);
// 修改裁剪参数,非正交摄像机
parameters.isOrthographic = false;
// 裁剪结果(可见对象、光源、反射探针)
CullingResults results = context.Cull(ref parameters);
/// 绘制部分
// 描述如何对已排序的可见对象进行排序 (sortingSettings) 以及使用哪些着色器通道 (shaderPassName)
DrawingSettings ds = new DrawingSettings();
// 设置此绘制调用可渲染的着色器通道。
ds.SetShaderPassName(0, new ShaderTagId("MyLight"));
// 如何在渲染期间对对象进行排序。
ds.sortingSettings = new SortingSettings(camera) { criteria = SortingCriteria.CommonOpaque };
/// 过滤部分
// 描述如何过滤给定的一组可见对象以便渲染,渲染区域(Bucket)和图层(Layer)
FilteringSettings fs = new FilteringSettings(RenderQueueRange.opaque, -1);
// 调度可见对象的Pass开头
context.DrawRenderers(results, ref ds, ref fs);
// 将所有调度命令都提交给渲染循环来执行
context.Submit();
}
}
}
Shader
前面说过,渲染管线需要依靠着色器才能进行渲染对象,那么接下来我们要创建一个Shader,在Shader中应用我们自己创建渲染管线。
虽然在当前版本下CG语言还可以使用,但是考虑到新版本迭代,我们将语法升级到HLSL,一些语法上的改动都在代码中备注了出来。这些对与该节来说都不是重点,我们只需要专注代码中的
Tags { "LightMode" = "MyLight" }
,该指令声明我们将采用我们自建管线中声明的渲染路径来渲染Pass。
Shader "SRP/UnlitTexture"
{
Properties
{
_Color ("Color Tint", Color) = (0.5, 0.5, 0.5)
}
HLSLINCLUDE// 这里我们不再使用CGINCLUDE,而是采用HLSL的写法
//在CG中使用的"UnityCG.cginc"文件变成了HLSL文件,并且由于它们是Unity的一个扩展组件,
//所以需要在Packages中找到相对应的库文件,里面定义了我们在URP中使用的主要的API
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
float4 _Color;
struct a2v
{
float4 position: POSITION;
};
struct v2f
{
float4 position: SV_POSITION;
};
v2f vert(a2v v)
{
v2f o;
// 由于不在使用CG的函数库,所以UnityObjectToClipPos函数也无法再使用
// 这时候我们使用HLSL函数库的TransformObjectToHClip方法将坐标从对象空间转换到裁剪空间
o.position = TransformObjectToHClip(v.position.xyz);
return o;
}
half4 frag(v2f v): SV_Target
{
// 在HLSL中,fixed4类型变成了half4类型
half4 fragColor = half4(_Color.rgb, 1.0) ;
return fragColor;
}
ENDHLSL
SubShader
{
Tags { "Queue" = "Geometry" }
LOD 100
Pass
{
// 这里的渲染队列对应了我们在自定义渲染管线中指定的Pass名
Tags { "LightMode" = "MyLight" }
// 这里也不在是CGPROGRAM
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
ENDHLSL
}
}
}
使用该Shader创建材质,然后赋值给场景中的立方体与球体。我们就可以看到下图没有计算光照的自定义管线结果。
增加光
CommandBuffer
在着色器中,我们要进行光照计算,通常需要使用一些Unity场景里的光源参数来参与计算过程,例如:环境光颜色(_LightColor0)、世界空间下环境光矢量(_WorldSpaceLightPos0)。在BRP中,只要我们设置好对应的渲染路径(Lightmode)名就可以直接访问这些参数。这些参数在渲染管线中被写入到一个全局的变量中,事实上,几乎所有的渲染管线都是以类似这种方式运行的。在Dx11中,这个全局变量被称为Constant Buffer(在HLSL代码里写为CBUFFER)。
至于如何使用C#代码来设置着色器的全局变量,其实也没有这么难啦。在Unity5.0中,CommandBuffer就已经提供给开发者了,但是当时的CommandBuffer更像是以“回调”的形式存在的,开发者只能使用Cb来扩展标准Untiy管线,在某一些地方添加自己的绘制指令。但是在SRP中,开发者负责整条管线所有的工作,Unity不会再为开发者提供这些默认的“内置变量”了,我们需要按照实际需求自己定义需要传给着色器的全局变量。
我们使用CommandBuffer的SetGlobalXXX类型的指令来设置着色器的全局变量。这个指令有以下几种类型:
- SetGlobalVector:设置一个float4类型的全局变量。
- SetGlobalColor:和上一个在Shader端效果相同,但是接受一个Color类型作为参数,而不是Vector。
- SetGlobalFloat:设置一个float类型的全局变量。
- SetGlobalMatrix:设置一个float4x4类型的全局变量。
- SetGlobalXXXArray:以上每一个类型都有一个对应的Array方法,可以设置一个定长数组作为着色器的全局变量。在之后的章节中,我们将使用这个特性支持多灯光渲染。
需要注意的是,对于全局向量类型,传入着色器的永远是四维向量(float4);对于全局矩阵类型,传入着色器的永远是4x4矩阵。如果您需要其它维度的向量和矩阵,需要填充至四维才能传入着色器。您也可以将多个低维向量打包成多个四维向量(比如normal、tangent、binormal在传递的时候可以使用三个向量的w通道保存一个点的世界坐标),只要记住打包的规则,在适当的时候对其进行正确解包就可以。
传递光源信息
为了传递光源信息给着色器,我们使用以下的步骤:
- 从CullResult中找到所有有效的光源列表。
- 从中找出第一个平行光,因为我们当前只考虑场景中单一的平行光光照。
- 将光源的信息通过着色器的全局变量传入着色器。
- 在Shader中读取光源信息并且计算光照。
1.从CullResult中找到所有有效的光源列表
我们知道CullResult中包括了所有场景中可见的物体。事实上,Unity在裁剪的时候,不仅仅裁剪物体,它会帮我们裁剪出以下内容:
- 所有在相机视锥范围内,且通过了Occlusion Culling的可见物体。
- 所有的可见光源。
- 反射探针(Reflection Probe)
对不同的光源种类,判断一个光源是否“可见”的标准是不一样的:
- 对于点光源来说,当且仅当它的照射范围(用一个以点光源为中心,照射范围为半径的圆球表示)与相机视锥体相交时,这个光源才会被保留以用于渲染。
- 对于射灯来说,当且仅当它的照射范围(一个锥形)与相机视锥体相交时,这个光源才会被保留用于渲染。
- 对于平行光来说,其一定会被保留用于渲染,因为平行光影响世界中所有的物体。
我们通过CullResult.visibleLights这个变量来获取经过裁剪之后得到的所有可见光源列表(List),每一个Light都有以下属性可以被读取:
- lightType:光源的类型,是LightType枚举的任意一项。
- localToWorld:光源的本地到世界矩阵,可以用这个读取光源的Transform属性。
- finalColor:光源的最终颜色,这个颜色是光源颜色乘以光源强度的总和,可以超过1。
2.从中找出第一个平行光
我们使用foreach循环遍历光源列表,判断其lightType是否为LightType.Directional,如果成立则执行渲染,如果不成立则简单地跳过当前光源。在下一个章节中我们就将学习如何使用多光源渲染。
3.将光源的信息通过着色器的全局变量传入着色器
我们可以通过SetGlobalVector和SetGlobalColor两个方法将光源的信息传递给Shader。在传入Shader参数的时候,我们需要指定变量的名称,但通常我们不会将名称的字符串直接传给Unity,而是利用字符串映射,使用一个整数(int)来代表一个变量名,因为整数的比较比字符串快很多,这样有助于提升管线的效率:
4.在Shader中读取光源信息并且计算光照
所有的着色器全局变量在Shader代码中都是“开箱即用”,只需要按照如下声明全局变量,就可以使用这些数据了:
uniform float4 _LightDir;
uniform float4 _LightColor;
代码实现
using Unity.Collections;
using UnityEngine;
using UnityEngine.Rendering;
// RenderPipeline:定义一系列描述 Unity 如何渲染帧的命令和设置。
public class SRenderPipeline : RenderPipeline
{
CommandBuffer buffer;
//这个函数在管线被销毁的时候调用。
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (buffer != null)
{
buffer.Dispose();
buffer = null;
}
}
/// <summary>
/// 定义此渲染管线的自定义渲染
/// </summary>
/// <param name="context">定义自定义渲染管线使用的状态和绘制命令</param>
/// <param name="cameras">所有的相机</param>
protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
if (buffer == null)
buffer = new CommandBuffer();
// 准备好光源名称和相机位置名称
var _LightDir = Shader.PropertyToID("_LightDir");
var _LightColor = Shader.PropertyToID("_LightColor");
var _CameraPos = Shader.PropertyToID("_CameraPos");
for (int i = 0; i < cameras.Length; i++)
{
Camera camera = cameras[i];
// 调度特定于摄像机的全局着色器变量的设置
context.SetupCameraProperties(camera);
/// 写入深度与背景,并设置相机位置
buffer.name = "Setup";
// 为颜色和深度缓冲区设置的渲染目标
buffer.SetRenderTarget(BuiltinRenderTextureType.CameraTarget);
// 设置渲染目标颜色为背景色
buffer.ClearRenderTarget(true, true, camera.backgroundColor);
// 设置相机的着色器全局变量_CameraPos
Vector4 CameraPosition = new Vector4(camera.transform.localPosition.x, camera.transform.localPosition.y, camera.transform.localPosition.z, 1.0f);
buffer.SetGlobalVector(_CameraPos, camera.transform.localToWorldMatrix * CameraPosition);
// 调度自定义图形命令缓冲区的执行。
context.ExecuteCommandBuffer(buffer);
buffer.Clear();
/// 裁剪部分
// 自定义一个裁剪参数,cullParam类里有很多可以设置的东西。
ScriptableCullingParameters parameters = new ScriptableCullingParameters();
// 填充来自摄像机的裁剪参数
camera.TryGetCullingParameters(out parameters);
// 修改裁剪参数,非正交摄像机
parameters.isOrthographic = false;
// 裁剪结果(可见对象、光源、反射探针)
CullingResults results = context.Cull(ref parameters);
/*
裁剪会给出三个参数:
可见的物体列表:visibleRenderers
可见的灯光:visibleLights
可见的反射探针(Cubemap):visibleReflectionProbes
所有的东西都是未排序的。
*/
// 可见光源的数组
NativeArray<VisibleLight> lights = results.visibleLights;
buffer.name = "RenderLights";
foreach (var light in lights)
{
//我们只处理平行光
if (light.lightType != LightType.Directional)
continue;
// 从光源的变换矩阵中获取第2列
Vector4 pos = light.localToWorldMatrix.GetColumn(2);
// 获取光源方向
Vector4 LightDirection = new Vector4(-pos.x, -pos.y, -pos.z, 0);
// 获取光源颜色
Color LightColor = light.finalColor;
//构建shader常量缓存
buffer.SetGlobalVector(_LightDir, LightDirection);
buffer.SetGlobalColor(_LightColor, LightColor);
// 调度自定义图形命令缓冲区的执行。
context.ExecuteCommandBuffer(buffer);
buffer.Clear();
break;
}
/// 绘制部分
// 描述如何对已排序的可见对象进行排序 (sortingSettings) 以及使用哪些着色器通道 (shaderPassName)
DrawingSettings ds = new DrawingSettings();
// 设置此绘制调用可渲染的着色器通道。
ds.SetShaderPassName(0, new ShaderTagId("MyLight"));
// 如何在渲染期间对对象进行排序。
ds.sortingSettings = new SortingSettings(camera) { criteria = SortingCriteria.CommonOpaque };
/// 过滤部分
// 描述如何过滤给定的一组可见对象以便渲染,渲染区域(Bucket)和图层(Layer)
FilteringSettings fs = new FilteringSettings(RenderQueueRange.opaque, -1);
// 调度可见对象的Pass开头
context.DrawRenderers(results, ref ds, ref fs);
// 调度天空盒的绘制
context.DrawSkybox(camera);
// 将所有调度命令都提交给渲染循环来执行
context.Submit();
}
}
}
运用了Blinn-Phong光照模型的Shader
Shader "SRP/BaseLit"
{
Properties
{
_Color ("Color Tint", Color) = (1, 1, 1)
// 漫反射强度
_DiffuseFactor ("Diffuse Factor", Range(0, 1)) = 1
// 高光反射强度
_SpecularFactor ("Specular Factor", Range(0, 1)) = 1
// 高光范围
_Gloss ("Gloss", Float) = 100
}
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl"
uniform float4 _CameraPos;
uniform float4 _LightDir;
uniform float4 _LightColor;
// 为了确保UnityShader可以兼容SRP批处理
// 需要把所有的材质属性声明在一个名为UnityPerMaterial的CBUFFER块中
CBUFFER_START(UnityPerMaterial)
float4 _Color;
float _DiffuseFactor;
float _SpecularFactor;
float _Gloss;
CBUFFER_END
struct a2v
{
float4 color: COLOR;
float3 position: POSITION;
float3 normal: NORMAL;
};
struct v2f
{
float4 pos: SV_POSITION;
float4 color: COLOR0;
float3 normalWorld: TEXCOORD1;
float3 worldPos: TEXCOORD2;
};
v2f vert(a2v v)
{
v2f o;
// 初始化变量
ZERO_INITIALIZE(v2f, o);
o.pos = TransformObjectToHClip(v.position);
o.normalWorld = TransformObjectToWorldNormal(v.normal);
o.worldPos = TransformObjectToWorld(v.position);
o.color = v.color;
return o;
}
half4 frag(v2f i): SV_Target
{
// 漫反射光=入射光线强度*材质的漫反射系数*漫反射强度*取值为正数(表面法线方向 · 光源方向)
half3 diffuse = _LightColor.rgb * _Color.rgb * _DiffuseFactor * max(0, dot(normalize(i.normalWorld), _LightDir.xyz));
// 视角方向 = 标准化(相机位置-坐标位置)
float3 viewDir = normalize(_CameraPos.xyz - i.worldPos.xyz);
// 半角方向
float3 halfwayDir = normalize(_LightDir.xyz + viewDir);
//BlinnPhong高光反射=高光强度*入射光线颜色强度*n次平方(取值为正数(法线方向 · 半角方向)); pow:次平方
float3 specular = _SpecularFactor * _LightColor.rgb * pow(max(0, dot(normalize(i.normalWorld), halfwayDir)), _Gloss);
// 最终结果 = 漫反射+高光反射
return half4(diffuse + specular, 1.0);
}
ENDHLSL
SubShader
{
Tags { "Queue" = "Geometry" }
LOD 100
Pass
{
// 这里的渲染队列对应了我们在自定义渲染管线中指定的Pass名
Tags { "LightMode" = "MyLight" }
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
ENDHLSL
}
}
}
下图中的球体应用了Blinn-Phong光照模型的Shader材质
后文
暂时只是粗略尝试了下SRP的实现过程,但只有这些,还远远不够。还有许多需要实现的地方,更多的光源、阴影、蒙版、透明度等等。想实现自己的SRP还有很长的路要走,不过有很多大佬已经在为我们铺路,下面给出两个国外大佬的教材网址,一个是基于Unity2018,一个是基于Unity2019。
Unity Scriptable Render Pipeline Tutotials (catlikecoding.com)
Bit:Catlike Coding Unity Tutorials / Scriptable Render Pipeline — Bitbucket