news 2026/10/24 2:42:46

URP Kawase Blur 全屏模糊 RendererFeature 拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
URP Kawase Blur 全屏模糊 RendererFeature 拆解

简介:这是一份面向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 # 波浪号结尾,导入时不会自动进 Assets

KawaseBlurFeature.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 间接引用它。

接线顺序是这样的:

  1. Project Settings > Graphics里的 Scriptable Render Pipeline Settings 指向一个 Universal Render Pipeline Asset。
  2. 打开这个 Asset,在 Inspector 的 Renderer List 里能看到若干 Renderer(比如默认的UniversalRenderer)。
  3. 选中对应 Renderer,Inspector 底部有Add Renderer Feature按钮,添加KawaseBlurFeature。
  4. 把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 上的参数不多,但每一个都直接影响效果和开销。先看表,后面拆代码时会对上号。

参数类型 / 范围作用调大之后的代价
blurShaderShader 引用模糊用的着色器为空则整个 Feature 静默失效
passEventRenderPassEvent 枚举Pass 插入时机越靠后越容易被 UI 采样到
iterationsint,0~4下降/上采样的级数RT 数量线性增长,带宽成倍上涨
blurRadiusfloat,0.1~5每级单次采样的像素偏移过大出现方块状伪影,边缘撕裂
downsampleOnFirstPassbool首级是否直接半分辨率关掉更清晰,但首帧开销明显
cameraLayerMaskLayerMask哪些相机层需要执行不设过滤会让场景视图和预览相机也跑一遍

数据流本身是一条折返路径:相机颜色 → 一级一级降到低分辨率 → 再从最低分辨率一级一级升回来 → 最后一级写回相机颜色目标。整个过程读写的都是颜色,不碰深度和法线,所以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.GetTemporaryRTHandle+ReAllocateIfNeeded
分配时机每帧申请、每帧释放尺寸变化时才重建,其余帧复用
XR 支持需手动处理 texture array 和切片内部按vrUsage自动处理
释放责任忘记调用必泄漏Dispose里统一Release
与 Blitter 配合需自己拆 source/destBlitCameraTexture直接吃句柄
推荐版本全版本可用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)
11/255约 4
21/2 → 1/45 + 914约 12
31/2 → 1/4 → 1/85 + 9 + 923约 28
41/2 → … → 1/165 + 9×332约 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 里其实可以只采中心点,靠硬件插值把细节补上——代价是边缘会出现轻微的阶梯感。项目保留了

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 2:40:56

Transcriber语音标注工具:高精度毫秒级声学事件标注实践指南

简介&#xff1a;本资源是开源语音标注工具Transcriber的完整安装与运行包&#xff0c;面向语音处理研究者、AI模型训练工程师及多媒体内容制作者&#xff0c;解决语音数据人工转录效率低、时间轴对齐难、多人协作不便等核心问题。压缩包共307个文件&#xff0c;涵盖87个Tcl脚本…

作者头像 李华
网站建设 2026/10/24 2:40:55

SpringBoot 实战:RTSP/RTMP 转 HTTP-FLV 与 WebSocket-FLV 流媒体网关

简介&#xff1a;面向 Java 后端开发者的流媒体播放集成资源&#xff0c;聚焦于将 RTSP、RTMP 等流地址经服务端转换后推送给前端&#xff0c;以 FLV 与 WebSocket 两种方式实现浏览器端播放&#xff0c;适合具备 Spring Boot 基础、需要快速落地视频监控或直播预览场景的中级开…

作者头像 李华
网站建设 2026/10/24 2:40:49

技术博文生成需匹配技术素材:部署、接口与排错规范

这个标题和配套材料&#xff08;“国际传播纪录片《大道——永远的长征》发布仪式”、“将台堡红军长征会师纪念园”等&#xff09;属于新闻/历史纪念类题材&#xff0c;与 CSDN 技术博客所需的技术项目、工具、模型、部署、接口、批量任务等内容不匹配&#xff1b;同时这类历史…

作者头像 李华
网站建设 2026/10/24 2:40:49

配电网二阶锥最优潮流Matlab实现:原理、代码与实操指南

1. 项目概述1.1 为什么配电网上最优潮流要用“松弛”这招先聊点直接的。配电网最优潮流&#xff08;Optimal Power Flow&#xff0c;OPF&#xff09;这个事儿&#xff0c;本质上是想回答一个问题&#xff1a;在满足电压、潮流、设备容量这些约束的前提下&#xff0c;怎么调节分…

作者头像 李华
网站建设 2026/10/24 2:40:35

基于Java+SpringBoot+SSM的高校党建系统设计与实现

做高校信息化项目这些年&#xff0c;接触最多的就是各类管理系统&#xff0c;而“基于JavaSpringBootSSM的高校大学生党建系统”绝对是这批项目里业务逻辑最完整、技术覆盖面也最典型的一个。很多人第一次看到这个题目会以为就是普通的CRUD&#xff0c;真正做下来才发现&#x…

作者头像 李华
网站建设 2026/10/24 2:40:16

动作教学视频批量处理:FFmpeg+Whisper+MediaPipe工具链

24式简化太极拳的完整版视频&#xff0c;配上晨练随拍的镜头&#xff0c;这类内容在短视频平台上非常常见&#xff1a;一个机位、一个人、一套动作从头打到尾&#xff0c;背向演练方便跟着学&#xff0c;正面机位则更适合看清手型步法。但真要把“一条视频”变成“一套能反复用…

作者头像 李华