简介:这是一份面向Unity URP学习者与渲染开发者的实验性Kawase模糊项目,基于Unity 2019.3构建,重点演示如何通过ScriptableRenderFeature在URP中实现后处理式模糊效果。项目围绕CommandBuffer多趟渲染、弹性降采样、可变模糊次数与临时纹理Blit等环节展开,并附带磨砂玻璃场景,便于理解模糊管线从设置到落地的组织方式。压缩包共66个文件,约133KB,包含场景、材质、Shader、ShaderGraph、Preset、C#脚本及README等,目录覆盖Assets、Settings、Materials、Scripts与ProjectSettings,适合按模块查阅。已有865人学习,可作为研究URP自定义RenderFeature、多Pass后处理与Kawase模糊实现的参考工程。
1. 项目定位:一个只做模糊的 URP Demo,为什么值得单独拆
半透明 UI 面板弹出来的时候,背后 3D 场景要是原样贴上去,那种廉价感后期调色是救不回来的。URP 里干这件事的常规路子是挂一个 RendererFeature,在相机渲染完不透明物体、还没叠 UI 的时间窗口里插一趟全屏模糊。这个 urp_kawase_blur 项目就是把这条链路抽出来单独做的测试工程:一个 Kawase 模糊的 RendererFeature、一支配套 Shader、一个用来验证效果的示例场景,没有别的杂物。
它不追求做成通用后处理库,价值恰恰在链路足够短——短到你能在一晚上把 URP 的 RenderPass 生命周期、RTHandle 分配、Blitter 采样这三件事一次性看明白。适合已经写过自定义 Pass、但每次都被 renderPassEvent 和 RT 格式坑到的人。
2. 工程结构与选型:一份 Feature 加一个 Shader 的极简组合
拿到包先别急着双击场景。这个项目看着文件少,但每个文件的职责切得很干净,先把结构摸清楚,后面改起来才不会到处漏。它的组织方式是典型的 URP 自定义渲染功能写法:运行时逻辑放 Runtime,着色器放 Shaders,材质和示例场景单独搁。之所以强调这一点,是因为很多人会直接把 Feature.cs 丢进 Assets 根目录,等到打 AssetBundle 或者做包体裁剪的时候,Shader 被当成未引用资源剔掉,游戏里一片纯色,查半天查不出来。
2.1 目录结构与文件职责
工程解压后大致是下面这个形状,我按职责标了注释:
Assets/ └── KawaseBlur/ ├── Runtime/ │ ├── KawaseBlurFeature.cs # RendererFeature 入口,挂在 Renderer Data 上 │ ├── KawaseBlurPass.cs # ScriptableRenderPass 子类,真正干活的地方 │ ├── KawaseBlurVolume.cs # 可选:Volume 参数容器,二次开发用 │ └── KawaseBlur.Runtime.asmdef # 程序集定义,必须显式引用 URP 运行时 ├── Shaders/ │ ├── KawaseBlur.shader # 两个 Pass:Downsample / Upsample │ └── KawaseBlur.hlsl # 公共采样函数,方便后续抽出去复用 ├── Materials/ │ └── KawaseBlur.mat # 引用上面那支 Shader,由 Feature 创建材质时替代 └── Samples~/ └── DemoScene.unity # 波浪号结尾,导入时不会自动进 AssetsKawaseBlurFeature.cs是 ScriptableObject,它只负责三件事:持有配置、创建材质、把 Pass 塞给渲染器。真正跟 CommandBuffer 打交道的是KawaseBlurPass.cs。这两个文件分工明确,是这套写法能复用的前提——把 Feature 当成「配置面板」,把 Pass 当成「执行单元」,以后想加个径向模糊或者景深,只需要再写一个 Pass 子类,Feature 那层几乎不用动。
Samples~目录名带波浪号是 Unity 的约定,导入时会被忽略,需要用户手动从 Package Manager 或者文件管理器里拖进来。这个设计在包体分发里很常见,好处是示例场景不会污染正式工程。
程序集定义文件内容大致如下,这是我建议照抄的部分:
{ "name": "KawaseBlur.Runtime", "rootNamespace": "KawaseBlur", "references": [ "Unity.RenderPipelines.Universal.Runtime", "Unity.RenderPipelines.Core.Runtime" ], "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": false, "autoReferenced": true }references里那两个是硬依赖,少一个就会在using UnityEngine.Rendering.Universal;那行报找不到类型。autoReferenced: true让业务层代码不需要额外引用就能访问到 Feature 类型。如果这个包要作为 UPM 包分发,把autoReferenced关掉、让使用者显式引用会更规范,但作为学习工程开着更省事。
2.2 接入 URP:Feature 到底挂在哪
很多新手卡在第一步:代码写完了,游戏里没反应。原因通常是 Feature 没挂对地方。URP 的自定义 Pass 不是挂在相机上的,而是挂在 Renderer Data 资产上,相机通过 Renderer Index 间接引用它。
接线顺序是这样的:
Project Settings > Graphics里的 Scriptable Render Pipeline Settings 指向一个 Universal Render Pipeline Asset。- 打开这个 Asset,在 Inspector 的 Renderer List 里能看到若干 Renderer(比如默认的
UniversalRenderer)。 - 选中对应 Renderer,Inspector 底部有
Add Renderer Feature按钮,添加KawaseBlurFeature。 - 把
Shaders/KawaseBlur.shader拖到 Feature 的 Shader 槽位。
这个顺序里第 4 步最容易忘。Feature 里对 Shader 的判断如果写成静默 return,Inspector 上看不出任何异常,运行起来就是不模糊。所以下面代码里我加了一条Debug.LogWarning,用血泪经验换来的——宁可控制台刷一行黄字,也不要让人对着屏幕猜半小时。
顺带说一个选型问题:为什么不直接用 URP 自带的 Volume 后处理?因为内置后处理栈里的 Bloom、DoF 都是写死在PostProcessPass里的,你要加一个自定义卷积核,要么改 URP 源码,要么在AfterRenderingPostProcessing事件上再插一个 Pass,而那时候 UI 已经画上去了,模糊会糊到 UI 上。Kawase 模糊的典型用途是「UI 背后的背景虚化」,必须卡在BeforeRenderingPostProcessing甚至更早,这个时机只有自定义 Pass 能精确控制。
2.3 参数与数据流:几个旋钮各自管什么
Feature 暴露在 Inspector 上的参数不多,但每一个都直接影响效果和开销。先看表,后面拆代码时会对上号。
| 参数 | 类型 / 范围 | 作用 | 调大之后的代价 |
|---|---|---|---|
blurShader | Shader 引用 | 模糊用的着色器 | 为空则整个 Feature 静默失效 |
passEvent | RenderPassEvent 枚举 | Pass 插入时机 | 越靠后越容易被 UI 采样到 |
iterations | int,0~4 | 下降/上采样的级数 | RT 数量线性增长,带宽成倍上涨 |
blurRadius | float,0.1~5 | 每级单次采样的像素偏移 | 过大出现方块状伪影,边缘撕裂 |
downsampleOnFirstPass | bool | 首级是否直接半分辨率 | 关掉更清晰,但首帧开销明显 |
cameraLayerMask | LayerMask | 哪些相机层需要执行 | 不设过滤会让场景视图和预览相机也跑一遍 |
数据流本身是一条折返路径:相机颜色 → 一级一级降到低分辨率 → 再从最低分辨率一级一级升回来 → 最后一级写回相机颜色目标。整个过程读写的都是颜色,不碰深度和法线,所以ConfigureInput只需要声明ScriptableRenderPassInput.Color。这一点跟 DoF 不一样,DoF 必须拿深度重建世界坐标,多声明一个深度输入就多一次拷贝。Kawase 的优势就在这——它压根不关心场景几何,纯 2D 图像处理。
passEvent的取值我一般锁死在BeforeRenderingPostProcessing。如果项目里 UI 是用 Screen Space - Overlay 画的,它根本不进渲染管线,放哪个事件都糊不到 UI;如果是 Screen Space - Camera,就必须赶在 UI 之前。这个区别决定了你后面要不要写 Stencil 遮罩。
3. 核心代码拆解:一个 RenderPass 从 Create 到 Execute 的完整链路
这一章是重点。项目里能抄作业的部分几乎全在Runtime目录那两个文件里,我按调用顺序讲:Feature 先被初始化,每帧向渲染器注册 Pass,Pass 在相机 Setup 阶段分配 RT,最后在 Execute 阶段提交命令。
3.1 Feature 层的三个重写方法
ScriptableRendererFeature只有三个必须关心的钩子:Create、AddRenderPasses、Dispose。项目里的实现大致如下,注释是我补的:
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; namespace KawaseBlur { public class KawaseBlurFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public Shader blurShader; // 为空则整个 Feature 失效 public RenderPassEvent passEvent = RenderPassEvent.BeforeRenderingPostProcessing; [Range(0, 4)] public int iterations = 2; // 下降采样的级数 [Range(0.1f, 5f)] public float blurRadius = 1.0f; // 每级采样的像素偏移 public bool downsampleOnFirstPass = true; // 首级是否直接半分辨率 public LayerMask cameraLayerMask = ~0; // 需要执行模糊的相机层 } public Settings settings = new Settings(); private KawaseBlurPass m_Pass; private Material m_Material; private bool m_Initialized; public override void Create() { // Create 在 Feature 被启用时调用一次,序列化字段变更后不一定重建 if (settings.blurShader == null) { Debug.LogWarning("[KawaseBlur] Shader 未赋值,Feature 不会执行"); return; } // 用 CoreUtils 建材质,管线销毁时会正确回收,比 new Material 安全 m_Material = CoreUtils.CreateEngineMaterial(settings.blurShader); m_Pass = new KawaseBlurPass(m_Material, settings) { renderPassEvent = settings.passEvent }; m_Initialized = true; } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (!m_Initialized || m_Pass == null || m_Material == null) return; var cameraData = renderingData.cameraData; // 预览相机和反射探针不需要模糊,跑一遍纯属浪费 if (cameraData.cameraType == CameraType.Preview) return; if (cameraData.cameraType == CameraType.Reflection) return; // 按层过滤,避免场景视图也一起糊掉 int layer = cameraData.camera.gameObject.layer; if ((settings.cameraLayerMask.value & (1 << layer)) == 0) return; // 每帧把 Pass 交给渲染器,注意这一步只是登记,不执行 renderer.EnqueuePass(m_Pass); } protected override void Dispose(bool disposing) { // RT 一定要在这里释放,否则编辑器下反复进出播放模式会累积泄漏 m_Pass?.Dispose(); CoreUtils.Destroy(m_Material); m_Initialized = false; } } }逻辑说明上,Create只跑一次,适合做材质创建这种「贵但只做一次」的事。别在里面分配 RT——RT 尺寸依赖相机分辨率,不同相机的分辨率不一样,必须在OnCameraSetup里按相机分配。
AddRenderPasses每帧对每个相机都会调一次,所以这里是做过滤的最佳位置。CameraType.Preview这个判断很关键:材质球预览、Shader 预览都会走一遍渲染管线,如果不拦,编辑器里滚动 Project 窗口都能感觉到卡顿。
Dispose是很多人忽略的重灾区。我在某项目里见过 RT 泄漏把显存吃满,表现是编辑器跑十几分钟后帧率断崖式下跌,用 Frame Debugger 查了半天才发现是 Feature 的临时 RT 没释放。这个项目里Dispose调了m_Pass?.Dispose(),把责任交给了 Pass 自己,这个设计值得学——谁申请谁释放,Feature 不越界。
还有一个隐蔽的坑:Unity 2019.3 之后默认开启了「Enter Play Mode Options」时关闭域重载,静态字段不会在进播放模式时清零。如果 Feature 里用了静态的 RT 缓存,第二次播放就会拿到已经释放的句柄。这个项目的 RT 全放在实例字段上,天然规避了。
3.2 Pass 的生命周期:OnCameraSetup 与 Execute 各干各的
这是整个项目最值得抄的一段。ScriptableRenderPass有四个生命周期方法,项目只用到了其中两个半,但用得都对。
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; namespace KawaseBlur { class KawaseBlurPass : ScriptableRenderPass { private const string k_ProfilerTag = "Kawase Blur"; private readonly ProfilingSampler m_Sampler = new ProfilingSampler(k_ProfilerTag); private readonly Material m_Material; private readonly KawaseBlurFeature.Settings m_Settings; private RTHandle[] m_DownRT = new RTHandle[8]; // 下降采样链的句柄 private RTHandle m_Source; // 相机颜色目标 private int m_ChainLength; // 实际使用的级数 public KawaseBlurPass(Material material, KawaseBlurFeature.Settings settings) { m_Material = material; m_Settings = settings; // 只声明颜色输入,不需要深度和法线,少一次拷贝 ConfigureInput(ScriptableRenderPassInput.Color); } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { var desc = renderingData.cameraData.cameraTargetDescriptor; desc.msaaSamples = 1; // MSAA 目标不能直接当纹理采样,必须关掉 desc.depthBufferBits = 0; // 模糊不需要深度,省一半带宽 desc.useMipMap = false; desc.autoGenerateMips = false; m_ChainLength = Mathf.Clamp(m_Settings.iterations, 1, 4); m_Source = renderingData.cameraData.renderer.cameraColorTargetHandle; int w = desc.width; int h = desc.height; for (int i = 0; i < m_ChainLength; i++) { // 首级可以直接减半,后续每级再减半 if (i > 0 || m_Settings.downsampleOnFirstPass) { w = Mathf.Max(1, w / 2); h = Mathf.Max(1, h / 2); } desc.width = w; desc.height = h; // 尺寸不变的帧不会重建,只在分辨率变化时重新分配 RenderingUtils.ReAllocateIfNeeded( ref m_DownRT[i], desc, FilterMode.Bilinear, TextureWrapMode.Clamp, name: $"_KawaseDown{i}"); } } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (m_Material == null || m_Source == null) return; var cmd = CommandBufferPool.Get(k_ProfilerTag); // ProfilingScope 让 Frame Debugger 和 Profiler 里能把这个 Pass 单独列出来 using (new ProfilingScope(cmd, m_Sampler)) { var src = m_Source; // 第一段:逐级下降采样 for (int i = 0; i < m_ChainLength; i++) { m_Material.SetFloat("_BlurOffset", m_Settings.blurRadius + i); Blitter.BlitCameraTexture(cmd, src, m_DownRT[i], m_Material, 0); src = m_DownRT[i]; } // 第二段:逐级上采样,注意别写回自己正在读的那张 for (int i = m_ChainLength - 2; i >= 0; i--) { m_Material.SetFloat("_BlurOffset", m_Settings.blurRadius + i); Blitter.BlitCameraTexture(cmd, src, m_DownRT[i], m_Material, 1); src = m_DownRT[i]; } // 末级结果写回相机颜色目标 Blitter.BlitCameraTexture(cmd, src, m_Source, m_Material, 1); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public void Dispose() { for (int i = 0; i < m_DownRT.Length; i++) { m_DownRT[i]?.Release(); m_DownRT[i] = null; } } } }先说OnCameraSetup。这个方法每帧每相机调一次,但ReAllocateIfNeeded内部会比对 descriptor,尺寸没变就直接复用,所以不会有每帧重建的开销。关键在于desc.msaaSamples = 1这行——如果相机开了 MSAA,cameraTargetDescriptor带出来的采样数是 4 或 8,这种 RT 不能当普通纹理采样,会直接报错或者采样出黑块。我第一次写的时候就是在 MSAA 开启的工程里踩了这个坑,画面全黑,日志里只有一行含糊的警告。
desc.depthBufferBits = 0也是必须的。模糊不需要深度,分配了就是白占带宽。移动端上这个差别挺明显,中低端机上一张 1080p 的深度缓冲能吃掉好几兆。
再说Execute里的两段循环。第一段的 stop condition 是i < m_ChainLength,从相机颜色一路降到最小分辨率;第二段从m_ChainLength - 2往回走,因为最后一级(m_DownRT[m_ChainLength - 1])本身就是源,不需要再降一次。第二段循环结束时src指向m_DownRT[0],最后单独 blit 一次回相机颜色。
这里有个必须注意的细节:Blitter.BlitCameraTexture的 source 和 destination 不能是同一张 RT。最后那次 blit 如果写成Blit(cmd, m_Source, m_Source, ...),在某些平台上会得到未定义行为,PC 上可能是对的,Android 上就是一片花屏。所以项目里始终保持「至少多一张中间 RT」的结构。
ProfilingScope是白送的收益。加了之后在 Frame Debugger 里这个 Pass 会独立成一个名为Kawase Blur的节点,Profiler 的 GPU 视图里也能单独看到耗时。不加的话所有 blit 混在Render Camera下面,想定位是谁在吃带宽基本靠猜。
3.3 临时 RT 的两种写法:RTHandle 与 GetTemporary 的取舍
项目用的是RTHandle这套相对新的 API。老教程里常见的是RenderTexture.GetTemporary配合CommandBuffer.Blit,两种写法差别不小,我列个表对比:
| 维度 | RenderTexture.GetTemporary | RTHandle+ReAllocateIfNeeded |
|---|---|---|
| 分配时机 | 每帧申请、每帧释放 | 尺寸变化时才重建,其余帧复用 |
| XR 支持 | 需手动处理 texture array 和切片 | 内部按vrUsage自动处理 |
| 释放责任 | 忘记调用必泄漏 | Dispose里统一Release |
| 与 Blitter 配合 | 需自己拆 source/dest | BlitCameraTexture直接吃句柄 |
| 推荐版本 | 全版本可用 | URP 12 及以上 |
老写法长这样,功能上没错,但每帧分配释放的 GC 和驱动开销在移动端是实打实的:
// 老写法:每帧申请、每帧释放 var temp = RenderTexture.GetTemporary(desc); cmd.Blit(m_Source.Identifier(), temp, m_Material, 0); cmd.Blit(temp, m_Source.Identifier(), m_Material, 1); RenderTexture.ReleaseTemporary(temp);这套写法在单相机场景下看不出问题,一旦工程里有多个相机叠加(比如主相机加一个武器相机),或者开启了 XR,RenderTexture.GetTemporary返回的可能是被别的系统复用的 RT,内容会串。RTHandle这一套就是为解决这类生命周期和 XR 兼容问题引入的。
如果你的工程还卡在 URP 10 之前,那就得用RenderTargetIdentifier那套更古老的 API,Blitter也不存在,得手写cmd.Blit。这个项目默认是 URP 12+ 起,Unity 2021.3 LTS 以上版本可以直接跑。
4. Kawase 算法落地:两趟采样怎么撑起一个大半径模糊
代码骨架有了,接下来是效果的核心——Shader。Kawase 这个名字听着玄,原理其实很朴素:既然要做大半径模糊,那就先把图缩小,缩小的过程本身就等于扩大采样范围,然后再放大回来,用双线性插值把中间的洞补平。双 Kawase 是在这个基础上,让下降和上升各用一次带权采样,用极少的采样次数逼近高斯。
4.1 算法拆解:为什么 8 次采样能顶一次大核高斯
标准高斯模糊在半径 16 的情况下,按可分离卷积算也要 33 次采样。Kawase 的做法是把半径拆成几何级数:第 0 级偏移 1 像素,第 1 级 2 像素,第 2 级 4 像素……累加起来的等效半径是指数增长的,而总采样次数只是线性的。
项目里用iterations控制级数,blurRadius控制基准偏移。两者共同决定的等效半径大致如下表,这是个帮助理解的估算,不是精确公式:
| iterations | 分辨率链 | 每级采样数 | 总采样数 | 等效模糊半径(px) |
|---|---|---|---|---|
| 1 | 1/2 | 5 | 5 | 约 4 |
| 2 | 1/2 → 1/4 | 5 + 9 | 14 | 约 12 |
| 3 | 1/2 → 1/4 → 1/8 | 5 + 9 + 9 | 23 | 约 28 |
| 4 | 1/2 → … → 1/16 | 5 + 9×3 | 32 | 约 60 |
可以看到采样数几乎不涨,等效半径是指数级膨胀。这就是 Kawase 在移动端吃香的原因——它把「模糊半径」这个昂贵的维度,换成了「RT 尺寸」这个便宜的维度。代价是极低分辨率下细节会丢失,画面里细小的亮线会被抹成一片雾,做 UI 背景虚化无所谓,做辉光就得配合阈值提取。
还有个容易混淆的点:Kawase 和 Box Blur 不是一回事。Box Blur 是取邻域平均,多少次采样就是多少次权重相等的累加;Kawase 是「缩放 + 采样」,偏移量随级别增长,本质上是在不同尺度上重复做小核卷积,叠出来的权重分布接近高斯但尾部更短。
4.2 Downsample Pass:8 次采样拼一个五抽头
下降采样的核心是四个对角采样加一个中心采样。项目里的 Shader 大致长这样,整支文件两个 Pass:
Shader "Hidden/KawaseBlur/KawaseBlur" { Properties { _BlurOffset ("Blur Offset", Float) = 1.0 } SubShader { Tags { "RenderPipeline" = "UniversalPipeline" } Cull Off ZWrite Off ZTest Always HLSLINCLUDE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl" float _BlurOffset; ENDHLSL // Pass 0:下降采样 Pass { Name "KawaseDownsample" HLSLPROGRAM #pragma vertex Vert #pragma fragment FragDown half4 FragDown(Varyings input) : SV_Target { float2 uv = input.texcoord; // 用源纹理的 texel size,别用 _ScreenParams,半分辨率链里会算错 float2 texel = _BlurOffset * _BlitTexture_TexelSize.xy; half4 col = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv) * 4.0; col += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2( texel.x, texel.y)); col += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(-texel.x, texel.y)); col += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2( texel.x, -texel.y)); col += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(-texel.x, -texel.y)); return col * 0.125; // 除以 8 } ENDHLSL } // Pass 1:上采样(帐篷核) Pass { Name "KawaseUpsample" HLSLPROGRAM #pragma vertex Vert #pragma fragment FragUp half4 FragUp(Varyings input) : SV_Target { float2 uv = input.texcoord; float2 texel = _BlurOffset * _BlitTexture_TexelSize.xy; // 3x3 帐篷核:中心 4,边 2,角 1,权重和 16 half4 col = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv) * 4.0; col += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2( texel.x, 0)) * 2.0; col += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(-texel.x, 0)) * 2.0; col += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(0, texel.y)) * 2.0; col += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(0, -texel.y)) * 2.0; col += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2( texel.x, texel.y)); col += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(-texel.x, texel.y)); col += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2( texel.x, -texel.y)); col += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(-texel.x, -texel.y)); return col * 0.0625; // 除以 16 } ENDHLSL } } }逐段说明。#include ".../Blit.hlsl"是 URP 提供的全屏三角形框架,里面已经定义好了Vert顶点函数、Varyings结构体(成员是positionCS和texcoord),还有_BlitTexture、_BlitScaleBias这些全局量。项目直接复用它,省掉了自己拼顶点数据的那几十行。
SAMPLE_TEXTURE2D_X这个宏比看起来重要。它不是简单的SAMPLE_TEXTURE2D别名,而是在 XR 单趟渲染(Single Pass Instanced)下会自动切成 texture array 的对应切片。如果写成普通采样,PC 上跑得好好的,上了 VR 头显就是一只眼有模糊另一只眼没模糊。
_BlitTexture_TexelSize.xy是源纹理的 1/宽、1/高。这里必须用源纹理的尺寸,不能用_ScreenParams。原因很直接:上采样那一趟的源是 1/8 分辨率的 RT,用屏幕尺寸算出来的 texel 会小 8 倍,偏移几乎为零,视觉上就是「只有最上面一级在模糊」。这个坑我在两个项目里都见过别人踩。
权重分配上,下降采样用 4:1:1:1:1,总和 8,所以乘 0.125;上采样用 4:2:2:2:2:1:1:1:1,总和 16,乘 0.0625。这两组数字是双 Kawase 的经典配置,改动之前先在纸面上把权重和验算到 1,否则整体亮度会偏。
4.3 上采样与最终合成:为什么最后一级要单独 blit
上采样的流程在 C# 那边已经写清楚了:从最低分辨率往上一级一级 blit,最后单独一次把m_DownRT[0]写回m_Source。
为什么不直接在循环里把最后一级写回相机颜色?因为相机颜色目标的尺寸和m_DownRT[0]不一样(m_DownRT[0]可能是半分辨率),如果循环里最后一脚直接写m_Source,源是 1/4、目标全分辨率,双线性插值会正常放大,但相机颜色的格式(可能是R11G11B10或者R8G8B8A8_sRGB)和m_DownRT[0]的格式可能不一致,Blitter会隐式做一次格式转换。大多数情况下没问题,但 sRGB 线性空间下这个转换会带来轻微偏色。单独拎出来一次 blit,表面上是多了一脚,实际上是让最后一次写入的格式转换发生在可控的位置。
另外,m_DownRT[0]用的FilterMode.Bilinear,所以上采样那一脚本身就自带一次插值。这意味着你在上采样 Shader 里其实可以只采中心点,靠硬件插值把细节补上——代价是边缘会出现轻微的阶梯感。项目保留了
本文还有配套的精品资源,点击获取