案例 25.1最小可用的 Feature:全屏反色、去色与染色入门
目标:写一个可以直接放进项目的全屏颜色效果:可调的反色、去色和染色。通过这个案例把“Feature + Pass + 全屏 Shader”的标准骨架完整走一遍,后面所有案例都在它的基础上变化。
原理:全屏效果的本质是“读相机颜色 → 每个像素算一次 → 写回相机颜色”。GPU 上一张纹理不能在同一次绘制里既当输入又当输出(结果未定义,在部分平台上会直接出错),所以要借一张临时 RT 中转:
先看 Shader。全屏 Pass 不需要自己写顶点着色器:core 包的 Runtime/Utilities/Blit.hlsl 已经提供了 Vert,它生成一个覆盖全屏的三角形,并把输入纹理统一命名为 _BlitTexture(25.4 节详细拆解)。我们只写片元着色器:
// 案例 25.1:最小的全屏效果——反色 / 去色 / 染色。 // 顶点着色器直接用 core 包 Blit.hlsl 里的 Vert(全屏三角形),输入纹理名固定为 _BlitTexture。 Shader "Cookbook/Ch25/Tint" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } // 全屏 Pass 的标配:不测深度、不写深度、不剔除 ZTest Always ZWrite Off Cull Off Pass { Name "Tint" HLSLPROGRAM #pragma vertex Vert #pragma fragment Frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" // Blit.hlsl 提供:_BlitTexture、_BlitScaleBias、Attributes/Varyings、Vert()、sampler_LinearClamp 等 #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl" // 这些参数由 C# 每帧写入材质,不放 Properties 块 float4 _TintColor; // 乘色 float _Invert; // 0..1 反色程度 float _Desaturate; // 0..1 去色程度 half4 Frag(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv); // 1) 去色:用 Rec.709 亮度权重(相机颜色缓冲是线性空间) half luma = dot(color.rgb, half3(0.2126, 0.7152, 0.0722)); color.rgb = lerp(color.rgb, luma.xxx, _Desaturate); // 2) 反色:在 sRGB(感知)空间里做 1-x 才像“底片”,在线性空间直接反会整体发白 half3 srgb = LinearToSRGB(saturate(color.rgb)); half3 inverted = SRGBToLinear(1.0 - srgb); color.rgb = lerp(color.rgb, inverted, _Invert); // 3) 染色 color.rgb *= _TintColor.rgb; return color; } ENDHLSL } } }
// 案例 25.1:最小的全屏效果——反色 / 去色 / 染色。 // 顶点着色器对应 core 包 Blit.hlsl 里的 Vert(全屏三角形),输入纹理名固定为 _BlitTexture。 Shader "Cookbook/Ch25/Tint" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } // 全屏 Pass 的标配:不测深度、不写深度、不剔除 ZTest Always ZWrite Off Cull Off Pass { Name "Tint" GLSLPROGRAM // _BlitTexture、_BlitScaleBias 由引擎(Core.glsl)提供,相当于 Blit.hlsl VARYING vec2 texcoord; #ifdef VERTEX // Blit.hlsl 的 Vert:用 gl_VertexID 生成一个盖住全屏的大三角形 void main() { vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); texcoord = p * _BlitScaleBias.xy + _BlitScaleBias.zw; gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); } #endif #ifdef FRAGMENT // 这些参数由 C# 每帧写入材质,不放 Properties 块 uniform vec4 _TintColor; // 乘色 uniform float _Invert; // 0..1 反色程度 uniform float _Desaturate; // 0..1 去色程度 vec4 Frag() { vec2 uv = texcoord; vec4 color = texture(_BlitTexture, uv); // 1) 去色:用 Rec.709 亮度权重(相机颜色缓冲是线性空间) float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722)); color.rgb = mix(color.rgb, vec3(luma), _Desaturate); // 2) 反色:在 sRGB(感知)空间里做 1-x 才像“底片”,在线性空间直接反会整体发白 vec3 srgb = LinearToSRGB(saturate(color.rgb)); vec3 inverted = SRGBToLinear(1.0 - srgb); color.rgb = mix(color.rgb, inverted, _Invert); // 3) 染色 color.rgb *= _TintColor.rgb; return color; } void main() { fragColor = Frag(); } #endif ENDGLSL } } }
反色这一步值得多看一眼。相机颜色缓冲在线性空间里(项目是 Linear 色彩空间),如果直接 1 - color,中灰 0.18 会变成 0.82,显示出来是接近白色的浅灰——整张图发白、发灰,不像底片。正确做法是先转到感知均匀的 sRGB 空间再取反,再转回线性:LinearToSRGB / SRGBToLinear 来自 core 的 Color.hlsl,Blit.hlsl 已经 include 了它。另外,HDR 颜色可能大于 1,取反前要先 saturate,否则会得到负数。
然后是 Feature。注意它只做三件事:建材质与 Pass、决定是否入队、把相机目标交给 Pass:
public class TintFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public RenderPassEvent passEvent = RenderPassEvent.BeforeRenderingPostProcessing; public Color tint = Color.white; [Range(0, 1)] public float invert = 1f; [Range(0, 1)] public float desaturate = 0f; } public Settings settings = new Settings(); // 在 Inspector 里拖入 Cookbook/Ch25/Tint。直接引用能保证打包时 Shader 不被剔除; // 留空时退回 Shader.Find(只在编辑器或 Shader 已被别处引用时可靠)。 public Shader shader; Material m_Material; TintPass m_Pass; // Create 在 OnEnable、以及 Inspector 每次改值(OnValidate)时都会被调用, // 所以这里必须先清理上一次创建的东西,否则每改一次参数就泄漏一份材质和 RT。 public override void Create() { m_Pass?.Dispose(); CoreUtils.Destroy(m_Material); if (shader == null) shader = Shader.Find("Cookbook/Ch25/Tint"); m_Material = shader != null ? CoreUtils.CreateEngineMaterial(shader) : null; m_Pass = new TintPass(m_Material); } // 每个相机、每帧调用一次:决定这一帧要不要把 Pass 放进队列 public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (m_Material == null) return; var camType = renderingData.cameraData.cameraType; // 材质球预览、反射探针烘焙用的相机不需要全屏效果 if (camType == CameraType.Preview || camType == CameraType.Reflection) return; m_Pass.renderPassEvent = settings.passEvent; m_Pass.Setup(settings); renderer.EnqueuePass(m_Pass); } // 相机的颜色/深度目标在这里才已分配好;在 AddRenderPasses 里取会报错 public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData) { m_Pass.SetTarget(renderer.cameraColorTargetHandle, renderer.cameraDepthTargetHandle); } // Renderer 被销毁(切换管线资产、改 Renderer 列表、退出)时调用 protected override void Dispose(bool disposing) { m_Pass?.Dispose(); CoreUtils.Destroy(m_Material); } }
// 案例 25.1 / 25.2:TintFeature 的网页版(对应 Ch25/TintFeature.cs)。 // 结构和 C# 一样:Feature 负责建材质、按 settings.passEvent 入队;Pass 负责“相机颜色 →(材质)→ 临时 RT → 拷回相机颜色”。 import { Material } from '../engine/shader.js'; import { RenderPassEvent } from '../engine/features.js'; export default class TintFeature { constructor(engine, data, scene) { this.engine = engine; this.scene = scene; this.data = data || {}; // C# Settings 的默认值 this.settings = { passEvent: RenderPassEvent.BeforeRenderingPostProcessing, tint: [1, 1, 1, 1], invert: 1, desaturate: 0, ...(this.data.settings || {}) }; } async init() { // Create():shader 留空时退回 Shader.Find("Cookbook/Ch25/Tint") const name = (this.data.shader && this.data.shader.shader) || 'Cookbook/Ch25/Tint'; this.material = new Material(await this.engine.shaders.get(name)); } addPasses(frame, enqueue) { if (!this.material) return; const s = this.settings, m = this.material; // TintPass.Setup:这些值在一帧内不变,直接写材质 // 注意:C# 的 material.SetColor 写的是 Properties 块里没声明的属性,Unity 原样上传、不做 sRGB→线性(和书中图实测一致),所以用 setVector m.setVector('_TintColor', s.tint); m.setFloat('_Invert', s.invert); m.setFloat('_Desaturate', s.desaturate); enqueue(s.passEvent, (fr) => { // OnCameraSetup:临时 RT 和相机颜色同尺寸、同格式,不要 MSAA、不要深度 const temp = fr.getTemporaryRT('_Ch25TintTemp', fr.width, fr.height); // 不能边读边写同一张纹理:先“相机颜色 → 临时 RT(经过材质)”,再拷回 fr.blit(fr.cameraColorTexture(), temp, m, 0); fr.blit(temp.texture, 'camera'); // 恢复相机的颜色 + 深度目标 fr.bindCamera(); }, 'Ch25 Tint'); } }
逐段说明:
- Shader 引用:
public Shader shader让使用者在 Inspector 里直接拖入 Shader。这不只是方便——Shader 被资源引用后,打包时才会被包含进去;只靠Shader.Find的话,如果没有任何材质用到它,打包后会找不到。教学代码保留了Shader.Find兜底,正式项目建议拖引用,或者把 Shader 加进 。 - 跳过不需要的相机:
CameraType.Preview是材质球、模型预览窗口的相机,CameraType.Reflection是反射探针烘焙用的相机。全屏改色效果对它们没有意义,还会让预览窗口里的材质球也被反色。Scene 视图相机(CameraType.SceneView)要不要处理看需求,这里保留,方便在 Scene 视图里直接看到效果。 - AddRenderPasses 与 SetupRenderPasses 的分工:在
AddRenderPasses被调用时,UniversalRenderer 还没决定这一帧要不要创建中间颜色纹理,cameraColorTargetHandle没有意义。源码里的保护是这样的(摘自 URP 14ScriptableRenderer.cs):
public RTHandle cameraColorTargetHandle { get { if (!m_IsPipelineExecuting) { Debug.LogError("You can only call cameraColorTarget inside the scope of a ScriptableRenderPass. ..."); return null; } return m_CameraColorTarget.handle; } }
m_IsPipelineExecuting 在 ScriptableRenderer.Execute 的第一行才被置为 true,紧接着就是 SetupRenderPasses。所以 URP 14 的规矩是:相机目标只能在 SetupRenderPasses(或之后的 Pass 回调)里取。很多从 URP 12 迁移过来的代码在 AddRenderPasses 里写 renderer.cameraColorTarget,升级到 14 后就会刷屏报错。
再看 Pass:
class TintPass : ScriptableRenderPass { static readonly int s_TintColor = Shader.PropertyToID("_TintColor"); static readonly int s_Invert = Shader.PropertyToID("_Invert"); static readonly int s_Desaturate = Shader.PropertyToID("_Desaturate"); readonly Material m_Material; RTHandle m_CameraColor, m_CameraDepth; // 借来的,不归我们释放 RTHandle m_Temp; // 自己分配的,Dispose 里释放 public TintPass(Material material) { m_Material = material; profilingSampler = new ProfilingSampler("Ch25 Tint"); // Frame Debugger 里显示的名字 } public void Setup(TintFeature.Settings s) { // 这些值在一帧内不变,直接写材质即可 m_Material.SetColor(s_TintColor, s.tint); m_Material.SetFloat(s_Invert, s.invert); m_Material.SetFloat(s_Desaturate, s.desaturate); } public void SetTarget(RTHandle color, RTHandle depth) { m_CameraColor = color; m_CameraDepth = depth; } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { // 临时 RT 和相机颜色同尺寸、同格式(HDR 时是浮点格式),但不要 MSAA、不要深度 var desc = renderingData.cameraData.cameraTargetDescriptor; desc.msaaSamples = 1; desc.depthBufferBits = 0; RenderingUtils.ReAllocateIfNeeded(ref m_Temp, desc, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_Ch25TintTemp"); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { var cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, profilingSampler)) { // 不能边读边写同一张纹理:先“相机颜色 → 临时 RT(经过材质)”,再拷回 Blitter.BlitCameraTexture(cmd, m_CameraColor, m_Temp, m_Material, 0); Blitter.BlitCameraTexture(cmd, m_Temp, m_CameraColor); // BlitCameraTexture 只绑定了颜色。URP 以为相机的颜色+深度还绑着, // 后面的天空盒/透明物体会直接画,所以由我们把深度一起恢复回去。 CoreUtils.SetRenderTarget(cmd, m_CameraColor, m_CameraDepth); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public void Dispose() { m_Temp?.Release(); m_Temp = null; } }
// 案例 25.1 / 25.2:TintFeature 的网页版(对应 Ch25/TintFeature.cs)。 // 结构和 C# 一样:Feature 负责建材质、按 settings.passEvent 入队;Pass 负责“相机颜色 →(材质)→ 临时 RT → 拷回相机颜色”。 import { Material } from '../engine/shader.js'; import { RenderPassEvent } from '../engine/features.js'; export default class TintFeature { constructor(engine, data, scene) { this.engine = engine; this.scene = scene; this.data = data || {}; // C# Settings 的默认值 this.settings = { passEvent: RenderPassEvent.BeforeRenderingPostProcessing, tint: [1, 1, 1, 1], invert: 1, desaturate: 0, ...(this.data.settings || {}) }; } async init() { // Create():shader 留空时退回 Shader.Find("Cookbook/Ch25/Tint") const name = (this.data.shader && this.data.shader.shader) || 'Cookbook/Ch25/Tint'; this.material = new Material(await this.engine.shaders.get(name)); } addPasses(frame, enqueue) { if (!this.material) return; const s = this.settings, m = this.material; // TintPass.Setup:这些值在一帧内不变,直接写材质 // 注意:C# 的 material.SetColor 写的是 Properties 块里没声明的属性,Unity 原样上传、不做 sRGB→线性(和书中图实测一致),所以用 setVector m.setVector('_TintColor', s.tint); m.setFloat('_Invert', s.invert); m.setFloat('_Desaturate', s.desaturate); enqueue(s.passEvent, (fr) => { // OnCameraSetup:临时 RT 和相机颜色同尺寸、同格式,不要 MSAA、不要深度 const temp = fr.getTemporaryRT('_Ch25TintTemp', fr.width, fr.height); // 不能边读边写同一张纹理:先“相机颜色 → 临时 RT(经过材质)”,再拷回 fr.blit(fr.cameraColorTexture(), temp, m, 0); fr.blit(temp.texture, 'camera'); // 恢复相机的颜色 + 深度目标 fr.bindCamera(); }, 'Ch25 Tint'); } }
- 描述符:临时 RT 直接从
cameraData.cameraTargetDescriptor复制,保证尺寸(已乘以 Render Scale)和格式(HDR 时是浮点格式)与相机颜色一致;再去掉 MSAA 和深度——全屏后处理既不需要多重采样也不需要深度缓冲,留着只会白白占用显存和带宽。 - ReAllocateIfNeeded:每帧都调用,但只有描述符变化(比如窗口大小改变)时才真正重新分配,详见 25.3 节。
- 命令缓冲:URP 14 里
RenderingData内部有一个commandBuffer字段,但它是internal的,只有 URP 自己的 Pass 能用。我们的 Pass 用CommandBufferPool.Get()取一个,录完交给context.ExecuteCommandBuffer,再还回池子。 - ProfilingScope:让这段命令在 Frame Debugger 和 Profiler 里显示为 “Ch25 Tint”,名字来自构造函数里设置的
profilingSampler。 - 最后一行的恢复:这是很多教程漏掉的一步,案例 25.2 会用实验说明为什么必须有它。
[BookShot("ch25-tint", W = 640, H = 400, Panels = 3)] static void Tint(Shot s) { Stage(s); if (s.Panel == 0) return; // 第 1 格:不加 Feature var f = s.Feature<TintFeature>(); if (s.Panel == 1) { f.settings.invert = 1; f.settings.desaturate = 0; } else { f.settings.invert = 0; f.settings.desaturate = 1; f.settings.tint = new Color(1.25f, 0.95f, 0.65f); } }

在编辑器里使用:
- 在 Project 窗口选中项目使用的 Renderer Data(新建 URP 项目里通常叫
xxx_Renderer)。 - Inspector 最下方 。
- 把
Cookbook/Ch25/Tint拖到 Shader 槽位,调整 Settings。Game 视图和 Scene 视图会立即生效。 - 打开 ,可以看到 “Ch25 Tint” 下面挂着两次 Draw Procedural(两次 Blit)。
ScriptableRenderPass 还提供了一个 Blit(CommandBuffer cmd, ref RenderingData data, Material material, int passIndex),它利用 UniversalRenderer 内部的双缓冲(_CameraColorAttachmentA/B):从当前颜色缓冲画到另一块,再交换前后台,省掉了“拷回”这一步。它确实更省一次全屏拷贝,但依赖内部的 SwapColorBuffer 行为,调试时也不如显式的临时 RT 直观。本书统一使用显式写法,读者理解原理后可以自行替换。