初探URP(一) - 认识ShaderLab与RenderFeature

本文最后更新于 2024年10月4日 下午

老本行。

使用Edit-Rendering-Materials-Convert Selected Built-in Materials to URP将Project面板中选中的Built-in材质转换为URP材质。

Shader结构

Properties块

用于暴露接口,定义Shader中的Uniform。

Shader属性来源包括Per-Instance、Material面板、全局属性。优先级从高到低排序。

Per-Instance属性可以使用MaterialPropertyBlock 类定义。

1
2
3
4
5
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Intensity("Intensity", Range(0, 1)) = 0.5
}
1
2
3
4
MaterialPropertyBlock block = new MaterialPropertyBlock();
renderer.GetPropertyBlock(block);
block.SetFloat("_Intensity", 0);
renderer.SetPropertyBlock(block);

如果只需要对某个对象的渲染进行临时、局部的修改,而不想影响材质的共享状态,使用 MaterialPropertyBlock 是更好的选择。如果需要修改材质的属性并希望修改是持久的,或者材质本身不与其他对象共享,使用 renderer.material.setFloat 会更简单。

二者区别:

renderer.material.setFloat:直接操作材质的 material 属性时,会隐式地对共享材质进行实例化。如果多个对象共享同一个材质,调用 renderer.material.setFloat 会让当前对象的材质与其他对象的材质分离,生成一个材质实例,导致多个对象不再共享同一个材质。这意味着这次修改只影响当前对象的材质,但会增加材质实例的内存开销。

renderer.getMaterialPropertyBlock:使用 MaterialPropertyBlock 可以避免实例化材质,修改的属性只会应用于当前的 Renderer 渲染的结果,并不会影响材质的共享状态。也就是说,如果多个对象使用同一个材质,通过 MaterialPropertyBlock 进行修改不会创建新的材质实例,而只是修改了该对象的渲染属性。

_MainTex(“Texture”,2D)=“white”{}

以该行为例。_MainTex指该属性的变量名,Texture指该属性在编辑器Inspector面板上的显示字样,2D指该属性的类型,等号后面的部分指该属性的默认值。

SubShader

每个Shader都可以包含若干SubShader,用于在对性能要求不同的平台进行Shader的不同实现。

Tags

Tags用于告诉GPU何时、如何执行Shader代码。

LOD等级

提高LOD等级可以降低着色器的性能消耗。

Pass

每个Pass会对物体进行一次渲染。每个Pass都包含了一个完整的顶点着色器和片段着色器。

创建URP Shader

首先,按创建Built-in Shader的方式,右键Project面板-Create-Shader-Unlit Shader。

随后,删除CGPROGRAMENDCG以及它们中间的部分。

URP头文件库使用HLSL编写。因此,使用URP时,所有的Shader代码都必须使用HLSL编写。

告诉Shader编译器,该Shader使用HLSL的方式如下:

在Pass前加入HLSLINCLUDEENDHLSL关键字,并在二者中间加入HLSL头文件名。该方式允许加入的头文件在所有Pass中共享。

1
2
3
4
5
6
7
HLSLINCLUDE
#include "Packages/com.unity.renderer-pipelines.universal/ShaderLibrary/Core.hlsl"
ENDHLSL
Pass
{
//HLSL Code
}

URP核心库的路径如下:

Packages/com.unity.renderer-pipelines.universal/ShaderLibrary/Core.hlsl

HLSLINCLUDEENDHLSL关键字之间,使用CBUFFER_STARTCBUFFER_END宏标记HLSL代码需要引用的属性所在的定义区域。

CBUFFER_START关键字后加上(UnityPerMaterial),让区域内的所有属性都能共用于所有Pass。

需要注意的是,纹理无需放在CBUFFER内。

1
2
3
4
5
6
CBUFFER_START(UnityPerMaterial)
float4 _BaseColor;
CBUFFER_END

TEXTURE2D(_MainTex); //定义贴图时,要同时定义纹理实例和采样器
SAMPLER(sampler_MainTex); //注意变量命名

HLSLINCLUDEENDHLSL关键字之间,需要定义顶点/偏远着色器输入结构体。

1
2
3
4
5
struct VertexInput
{
float4 position : POSITION;
float2 uv : TEXCOORD0;
};
1
2
3
4
5
struct VertexOutput
{
float4 position : SV_POSITION
float2 uv : TEXCOORD0;
}

这里的POSITIONTEXCOORD0被称为“语义”,用于告知编译器,这里的变量对应着什么属性,并自动处理数据输入。

常用的语义还有:

Shader语义 作用和意义 示例
POSITION 定义了每个顶点的位置。它必须在顶点着色器的输出中使用。 float4 pos : POSITION
NORMAL 定义了每个顶点的法向量。法向量是垂直于表面的向量,用于计算光照和阴影效果。 float3 normal : NORMAL
COLOR 定义了每个顶点的颜色。可以用它来表示材质的颜色或在顶点着色器中计算出的其他颜色。 float4 color : COLOR
TEXCOORD0-3 定义了每个顶点在纹理图像中的位置,以便将纹理贴到表面上。 float2 uv : TEXCOORD0
TANGENT 定义了每个顶点的切线向量。切线向量是表面切线方向的向量,用于计算法线贴图。 float3 tangent : TANGENT
BINORMAL 定义了每个顶点的副切线向量。副切线向量是表面副切线方向的向量,用于计算法线贴图。 float3 binormal : BINORMAL
SV_POSITION 定义了像素在屏幕空间中的位置。用于像素着色器的输出。 float4 pos : SV_POSITION
UNITY_FOG_COORDS 定义了雾坐标。用于计算雾效果。 float fogCoord : UNITY_FOG_COORDS
UNITY_VERTEX_ID 定义了顶点的索引。用于访问顶点缓冲区中的顶点数据。 uint vertexID : UNITY_VERTEX_ID
UNITY_MATRIX_MVP 定义了世界空间到裁剪空间的变换矩阵。 float4x4 MVP : UNITY_MATRIX_MVP
UNITY_LIGHTMODEL_AMBIENT 定义了环境光颜色。用于计算全局光照。 float4 ambient : UNITY_LIGHTMODEL_AMBIENT
UNITY_LIGHTMODEL_DIRECTION 定义了灯光模型的方向。用于计算方向光照。 float3 direction : UNITY_LIGHTMODEL_DIRECTION
UNITY_LIGHT_ATTENUATION 定义了光照的衰减值。用于计算点光源和聚光灯的光照衰减。 float3 attenuation : UNITY_LIGHT_ATTENUATION
UNITY_LIGHT_COOKIE 定义了灯光的贴图。用于在灯光上添加纹理效果。 sampler2D cookie :

在Pass内,通过HLSLPROGRAMENDHLSL关键字定义HLSL程序代码区域。

该代码段需要包含一个vert函数和frag函数。前者的返回类型为之前定义的VertexOutput类型,接收一个VertexInput参数;后者返回一个float4作为最终颜色,接收一个VertexOutput参数。需要注意的是,frag函数需要用SV_TARGET语义标记。

1
2
3
4
5
6
7
8
9
10
11
VertexOutput vert(VertexInput i)
{
VertexOutput o;
o.position = TransformObjectToHClip(i.position.xyz); //将顶点坐标由模型空间转换到裁剪空间
o.uv = i.uv;
}
float4 frag(VertexOutput o) : SV_TARGET
{
float4 baseTex = SAMPLER_TEXTURE2D(_MainTex, sampler_MainTex, o.uv)
return baseTex*_BaseColor;
}

此外,还需要在HLSLPROGRAM关键字下定义宏:

1
2
3
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag

了解Render Feature

RenderFeature允许我们在渲染管线的若干特定阶段插入我们需要的渲染过程,实现管线的自定义。

在Project面板中右键-Create-Rendering-URP Render Feature创建新Render Feature。

Render Feature资产表现为一个.cs文件。其结构如下:

1
2
3
4
5
6
7
public class CustomRenderPassFeature : ScriptableRendererFeature{
class CustomRenderPass;
CustomRenderPass m_ScriptablePass;
public override void Create();
public override void AddRenderPasses(ScriptableRenderer renderer, ref Rendering Data renderingData);
protected override void Dispose(bool disposing);
}

Create()方法默认如下,一般会在其中进行Pass实例化与渲染时间指定。

1
2
3
4
public override void Create(){
m_ScriptablePass = new CustomRenderPass(); //实例化Pass
m_ScriptablePass.renderPassEvent = RenderPassEvent.AfterRenderingOpaque; //指定该Pass的渲染时机
}

AddRenderPasses方法默认如下,一般用于将Pass实例入队:

1
2
3
public override void AddRenderPasses(ScriptableRenderer renderer, ref Rendering Data renderingData){
renderer.EnqueuePass(m_ScriptablePass); //将Create()中实例化的RenderPass插入到渲染管线
}

CustomRenderPass类定义是Render Feature的核心。其默认包含以下三个生命周期函数:

1
2
3
4
5
6
7
8
9
10
11
class CustomRenderPass : ScriptableRenderPass{
//在调用Pass前执行,用于创建临时RT(新版),给其分配内存
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor){}
//在Execute()前执行,可创建临时RT纹理或调整现有RT的参数,也可调整其他变量
//留空时,默认渲染到活跃相机的RT上
public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData){}
//实现渲染逻辑,即该RenderPass做的事,频繁执行
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData){}
//在Execute()后执行,用于释放OnCameraSetup()阶段声明的变量(例如临时RenderTexture)
public override void OnCameraClearup(CommandBuffer cmd){}
}

从相机RT获取Color纹理

Feature部分

Create方法中,我们已经创建了Pass实例并设置了渲染时机。

AddRenderPasses中,我们需要将Pass实例入队提交给上下文渲染。但这里有个问题:一般,我们撰写Pass的渲染逻辑应当作用于CameraType.Game的相机,如果作用于其他类型的相机,如Reflection等,可能会出错。所以,要做逻辑判断:

1
2
3
4
5
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData){
if (renderingData.cameraData.cameraType == CameraType.Game){
renderer.EnqueuePass(m_ScriptablePass);
}
}

SetupRenderPass用于在Pass实例正式起效前做一些预备工作,可以取代Pass内的OnCameraSetup函数。一般,我们会给Pass类写一个Setup方法,在里面进行Feature和Pass的实例传输(一般是各种Shader参数,RTHandle等)

1
2
3
public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData){
m_ScriptablePass.Setup(renderingData.cameraData.renderer.cameraColorTargetHandle);
}

Dispose是生命周期函数,在Feature不再生效时调用。一般,我们在这里调用Pass类内我们自定义的Dispose方法,用于释放资源。

1
2
3
protected override void Dispose(bool disposing){
m_ScriptablePass.Dispose();
}

Pass部分

Configure方法包含了对相机RT的Descriptor的引用。我们可以通过该Descriptor为临时RT分配内存:

1
2
3
4
//以outputDescriptor为RT参数,"_ExampleRT"为RT名,给RTHandle:m_OutputHandle分配内存
RenderingUtils.ReAllocateIfNeeded(ref m_OutputHandle, outputDescriptor, name:"_ExampleRT");
//设置m_OutputHandle为当前Pass的RT
ConfigureTarget(m_OutputHandle);

Execute方法可以理解为渲染管线中的Update,会频繁执行。

在该方法中,我们需要进行以下操作:

  • 从命令缓冲池中获取cmd实例
  • 将临时RT中的Render Texture取出,设置为全局Shader Texture变量,供Shader使用
  • 使用Blitter工具类,将相机RT中的颜色纹理复制到临时RT
  • 执行cmd内命令
  • 释放cmd实例
1
2
3
4
5
6
7
8
9
10
11
12
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
//从命令缓冲池中获取cmd实例
CommandBuffer cmd = CommandBufferPool.Get("tmpCmd");
//将临时RT中的Render Texture取出,设置为全局Shader Texture变量
cmd.SetGlobalTexture("_ExampleRT",m_OutputHandle);
//使用`Blitter`工具类,将相机RT中的颜色纹理复制到临时RT
Blitter.BlitCameraTexture(cmd, m_InputHandle, m_OutputHandle);
context.ExecuteCommandBuffer(cmd);
//释放cmd实例
CommandBufferPool.Release(cmd);
}

Dispose()方法在Feature的Dispose()函数中自动调用,释放临时RT资源:

1
2
3
4
public void Dispose()
{
m_OutputHandle?.Release();
}

Shader部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
Shader "Unlit/TestMat"
{
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"
CBUFFER_START(UnityPerMaterial)
//声明_MainTex变量
TEXTURE2D(_ExampleRT);
SAMPLER(sampler_ExampleRT);
CBUFFER_END
struct VertexInput
{
float4 position : POSITION;
float2 uv : TEXCOORD0;
};
struct VertexOutput
{
float4 position : SV_POSITION;
float2 uv : TEXCOORD0;
};
ENDHLSL

Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
VertexOutput vert(VertexInput i)
{
VertexOutput o;
o.position = TransformObjectToHClip(i.position);
o.uv = i.uv;
return o;
}
float4 frag(VertexOutput o) : SV_Target
{
float4 color = SAMPLE_TEXTURE2D(_ExampleRT, sampler_ExampleRT, o.uv);
return color;
}
ENDHLSL
}
}
}

效果

image-20241004140603802

踩坑

  1. 资源不应在OnCameraClear()中释放

OnCameraClear()会在每次Execute()执行完毕后执行,而临时RT资源只会在Configure()中分配一次。因此,在OnCameraClear()中释放临时RT会导致不停地释放无效资源,导致报错。

若一定要这么做,需要在OnCameraSetup()中申请临时RT而非Configure()中申请。

谨记:OnCameraSetup()OnCameraClear()成对,每帧执行;Configure()Dispose()成对,在每个Pass的生命周期中只应执行一次!

  1. 分配临时RT后,应当指定其为该Pass的渲染目标

使用ConfigureTarget(RTHandle)指定临时RT为当前Pass的RT。否则Blit将失效。

  1. 使用BlitCameraTexture时,sourcedestinationRenderTextureDescriptor参数应当一致。

必须尽可能严格一致,否则Blit将无法生效。

  1. Shader全局属性无需在Properties块中定义

Properties块中定义的属性始终由Inspector面板控制,且优先级始终高于全局Shader属性。


初探URP(一) - 认识ShaderLab与RenderFeature
http://example.com/2024/10/04/初探URP(一)-认识ShaderLab与RenderFeature/
作者
Yoi
发布于
2024年10月4日
许可协议