ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Compute Shader实现Unity后处理:从渲染管线到URP实践

用Compute Shader实现Unity后处理:从渲染管线到URP实践 做图形方向的朋友应该都遇到过这种尴尬项目里需要加一个像素风格化、Bloom 或者屏幕模糊之类的后期效果打开 Asset Store 找插件发现要么收费要么体积大得离谱要么只能在固定管线里跑。自己动手写吧用 OnRenderImage 全屏 Pass 叠来叠去帧率一上去带宽先扛不住。于是我后来把目光转向了计算着色器Compute Shader这一章就把我实际用 Compute Shader 做后处理和渲染管线整合的经验完整拆一遍。这一篇是系列的第 3 章前两章聊了 Compute Shader 的基础语法、线程组和内存模型还没看过的建议先补一下。如果没有基础这篇也能顺着思路走我会把渲染管线和后期处理的关系从零讲清楚再给一个能直接抄的完整示例从 CT 资源管理到 URP 的 RenderFeature 挂载都覆盖到。适合想彻底搞懂“图像怎么在 GPU 里流转”的 Unity 开发者也适合被后处理性能问题折磨过的朋友。1. 先从渲染管线说起一处像工厂流水线的分工1.1 应用阶段CPU 发指令GPU 干活很多初学者把渲染管线理解成“从模型到屏幕的一整套流程”这个理解没错但不够具体。真正的管线应该分成三个阶段应用阶段、几何阶段和光栅化阶段。应用阶段跑在 CPU 上你的 C# 脚本、动画系统、物理引擎、相机剔除、渲染命令提交全都发生在这里。说白了CPU 负责“指挥”GPU 负责“执行”两者之间通过 CommandBuffer 和 DrawCall 传递指令。你可以把应用阶段想象成餐厅的厨房下单流程客人点菜后前厅把订单送到后厨后厨根据菜单逐道出菜。CPU 的角色就是前厅的收银员GPU 是那几个灶台上的厨师。你每调用一次 Graphics.DrawMesh 或者让相机渲染一个物体Unity 都会往 GPU 的命令流里塞一条“订单”GPU 按顺序执行。正因为 CPU 和 GPU 是异步并行的所以帧率瓶颈经常出现在 CPU 提交命令的速度跟 GPU 执行速度不匹配上。1.2 几何与光栅化模型变屏幕像素的漫长旅程第二阶段几何阶段跑在 GPU 的顶点着色器Vertex Shader、几何着色器Geometry Shader、曲面细分着色器Tessellation Shader等可编程单元里。这一阶段做的事情有两件一是把模型空间、世界空间、观察空间、裁剪空间这些坐标空间来回变换最终算出每个顶点在屏幕上的位置二是做裁剪、背面剔除、视口变换把超出屏幕范围的东西尽可能早地扔掉。第三阶段是光栅化。GPU 把顶点连成三角形再通过插值生成覆盖在三角形上的每一个像素片元交给像素着色器Fragment Shader去上色。这里有个非常关键的细节像素着色器的工作是高度并行的而且天然没有顺序依赖——因为每个片元的颜色只跟它自身的输入数据相关这就为 GPU 的并行计算能力提供了极大的发挥空间。但传统渲染管线有个局限片元着色器只能“写”到当前绑定的 RenderTarget 的固定位置你不能甩开一个三角形去改屏幕另一边的像素也不能在一个 Pass 里同时读写多张纹理。这恰好是计算着色器能打破的边界。1.3 计算着色器的位置独立于管线的并行计算通道计算着色器并不属于传统固定管线里的任何一环它更像是一条“并行计算的旁路”。你可以把它理解为 GPU 里的一组“临时工”不需要走顶点着色器、光栅化、片元着色器那套固定的流程直接拿到线程组和显存数据想怎么算就怎么算。它能做的事包括但远不止后处理粒子模拟、布料解算、地形生成、软阴影、可见性剔除、骨骼蒙皮Compute Skinning、流场追踪全都能干。放在渲染管线的语境里计算着色器的位置通常在“渲染完一张图之后”和“把这张图交给下一阶段或送入屏幕前”之间。也就是说你完全可以在相机把场景绘制到一张 RenderTexture 上之后派一个 Dispatch 命令让几千个线程同时去读这张纹理、做一些计算再写入另一张纹理。画面没有走常规渲染流程但结果却被最终合成进了屏幕。这个模式就是我们常说的后处理Post-processing。从架构的视角看Compute Shader 最大的价值不是“能写出后处理”而是把“图像处理”从传统的图形 API 语义里解放了出来。传统片元着色器后处理必须构造一个覆盖全屏的三角形然后靠硬件帮你逐像素插值计算着色器则直接让你定义线程与像素的映射关系灵活性高了一个量级。后面我会专门写一节对比两者的性能账。2. 后处理为什么值得用计算着色器先算一笔账2.1 粗暴方案CPU 回读图像数据再处理先看最暴力的方案如果项目里要做某个效果你想“把屏幕截图从 GPU 拿回 CPU处理完再扔回 GPU”。这个方案在工程上不可行原因只有一个字慢。拿 1080p 的 RGBA32 图像来说一张图约 8MB 数据。假设每秒 60 帧光是读写一次的带宽需求就接近 1GB/s。而从 GPU 回读数据到 CPU 还要经过 PCIe 总线延迟以毫秒计再加上 CPU 端循环逐像素处理的开销帧率直接崩掉。而且回读会造成 GPU 流水线强制同步也就是说 GPU 要等 CPU 算完才能继续干别的整个管线的并行性瞬间报废。所以在游戏这样的实时渲染场景里后处理必须保持在 GPU 上完成所有计算都留在显存内部流转。计算着色器恰恰就是这个思路的完美载体。2.2 传统方案全屏 Fragment Shader 逐 Pass 叠加接下来是大家更熟悉的传统做法写一个全屏四边形用一个或多个 Fragment Shader 做处理再利用 Unity 的 OnRenderImage 或者 ScriptableRenderPass.Blit 来串起多 Pass。这个方案能跑但有几个让我很不舒服的痛点。首先是多 Pass 的渲染目标切换开销。假设你要做一个三层高斯模糊每层都要先渲染到中间纹理、再读取这张纹理做下一次滤波至少会产生 4 到 6 次 RenderTexture 切换。每切换一次GPU 都要执行资源屏障Resource Barrier流水线会停顿频率一高压力就大。第二个痛点是无法在同一个 Pass 内做大范围的领域操作。比如你想对图像做 9x9 的卷积需要访问当前像素周围 81 个点。在 Fragment Shader 里你得主动采样 81 次纹理而且边界处理还要自己写 clamp。在需要多个卷积核串联的时候代码会变得非常臃肿还容易因为依赖关系处理不好造成性能灾难。第三个痛点也是最重要的Fragment Shader 的输出局限于当前 RenderTarget 的像素位置你没法“顺手”给另一个无关的像素位置写数据。这让很多创意效果很难做比如光晕、拖尾、变形效果逻辑上需要在邻近区域做数据交换传统片元流程要绕很多弯。2.3 计算着色器共享内存与显式调度带来的优势计算着色器在这几个维度上都比 Fragment Shader 更适合后处理。第一你可以显式控制线程组大小和调度数量。想处理一张 1920x1080 的纹理如果每个线程负责一个像素每个线程组有 8x8 个线程那么调度次数就是 (19207)/8 x (10807)/8也就是 240x135 个线程组。Count 数可以精确算出来完全由你掌控。第二你可以利用线程组内的共享内存Groupshared。这句话的含义非常大——它意味着一个线程组内的 64 个线程可以先把一块图像数据加载到高速缓存里然后所有线程反复读取这块缓存而不需要每次都访问全局显存。做模糊、卷积这类后处理时这个特性可以直接把多次纹理采样换成一次 Load 加多次缓存访问性能差距可以有几倍。第三计算着色器允许多读多写甚至可以“写”到指定坐标。比如做一个类似“把图像向左位移 10 像素”的效果在 Fragment 里你需要采样偏移后的位置在 Compute 里你直接让线程 id 对应的目标坐标去源纹理里取数写进去就行。写完一个目标还能再写另一个做多阶段效果很方便。第四它天然地适合做串联管线。你可以用一个 Dispatch 做降采样下一个 Dispatch 读降采样的输出模糊后输出到第三张纹理每一步都是独立的 kernel 调用虽然 Dispatch 之间也需要屏障但比切换 RenderTarget 的成本轻得多代码逻辑也更清晰。2.4 用在哪、用在多大的效果上合适判断一个后处理到底该用 Fragment Shader 还是 Compute Shader我个人的标准很简单如果只是调节颜色、曝光、饱和度这类逐像素独立变换Fragment Shader 一点都不差因为它有硬件自动插值和光栅化帮你分配好每个像素的执行写起来也短。但如果效果需要访问周围像素、需要多张纹理做累积、需要多次反复迭代或者想做一些“反常规”的像素交换那就该上 Compute Shader。还有一类场景是移动端Compute Shader 的适用性需要额外评估。很多中低端 Android 设备对 Compute Shader 的支持并不完美ES 3.1 以后理论上都支持但驱动实现好坏差距很大。我的建议是如果要写跨平台的通用后处理链最好做一个开关在支持的设备上走 Compute 路径不支持时回退到 Fragment 方案。后面我会把这样的兼容代码写进示例。3. 核心前置图像进、图像出、资源管理3.1 RenderTexture 与临时 RT 的创建和释放不管用什么方案后处理的第一步都是拿到一张已经渲染好的场景图。在 Unity 里通常用 RenderTexture 来承载。你可以直接用 new RenderTexture(width, height, 0, format) 创建但更推荐使用 RenderTexture.GetTemporary 来获取临时 RT因为它会从预分配的缓冲区池里复用避免频繁创建和销毁造成的 GC 和显存碎片。比如RenderTextureDescriptor desc new RenderTextureDescriptor(Screen.width, Screen.height, RenderTextureFormat.DefaultHDR, 0); var tmp RenderTexture.GetTemporary(desc);用完以后记得调用 RenderTexture.ReleaseTemporary(tmp)。这里有个很容易踩的坑如果在一个后处理脚本里每帧都 GetTemporary 而不释放显存会被快速占满然后系统开始疯狂 GC帧率断崖式下降。如果你在项目里看到帧率每隔几秒卡一下优先检查是不是 RT 泄漏了。另外要注意 RT 的 release 时机。因为 GPU 是异步的你在 C# 里调用 ReleaseTemporary 不代表 GPU 立刻就能释放这块资源Unity 内部会处理引用计数。但如果你是自己在 RenderPipelineManager.endCameraRendering 里做后处理就要格外小心不要在回调里直接释放正在被 GPU 使用的 RT。稳妥的做法是维持一个队列延迟一帧再释放。3.2 格式选择格式不对画面分分钟发黑后处理经常遇见“明明是 HDR 颜色一处理后却变成死黑或死白”的诡异现象十有八九是 RenderTextureFormat 和纹理采样格式不匹配导致的。场景图通常是 DefaultHDR也就是 R16G16B16A16_Float 之类的浮点格式能存超过 1.0 的亮度值。你如果把它拷到 R8G8B8A8_UNorm 这种 8 位归一化纹理里超过 1.0 的数值会被截断高光细节全丢再做什么 Bloom 也是白做。所以中间处理纹理尽量用浮点格式比如 R16G16B16A16_Float、R11G11B10_Float或者更省带宽的 R16F如果只需要单通道亮度。R11G11B10_Float 是我在移动端很喜欢用的一种格式RGB 各占 11/11/10 位浮点带宽只有 RGBA32F 的一半精度对大部分效果也够。不过要注意它没有 Alpha 通道如果你后续要做混合就得额外考虑。还有一点在计算着色器里读写 RWTexture2D 时格式必须和你绑定的 RT 格式对得上。比如你绑定的 RT 是 R8G8B8A8_UNorm那核函数里就要声明 RWTexture2Dfloat4并且写入时自动归一化到 0~1如果声明成了 RWTexture2Dfloat4 但绑定的是 R16G16B16A16_Float也能跑但行为和精度会有差异。尤其在 Metal 上格式不匹配可能导致写入全部变成 0。所以写完后处理的第一个自查项就是“格式对不对”。3.3 绑定与调度SetTexture、Dispatch 的完整流程在 Unity 里用 ComputeShader 做后处理标准流程是computeShader.SetTexture(kernelIndex, inputTexture, sourceRT); computeShader.SetTexture(kernelIndex, outputTexture, destRT); computeShader.SetVector(_TexelSize, new Vector4(1.0f / width, 1.0f / height, width, height)); computeShader.Dispatch(kernelIndex, Mathf.CeilToInt(width / 8.0f), Mathf.CeilToInt(height / 8.0f), 1);这里有几个新手容易迷糊的地方kernel 索引、线程组数量、纹理名称。kernel 索引是你在 ComputeShader 文件里用#pragma kernel SomeKernel声明后通过FindKernel(SomeKernel)拿到的整数 ID。每个 kernel 可以绑定不同的纹理和参数负责不同的处理阶段。你可以把“模糊横向”和“模糊纵向”分成两个 kernel也可以写在同一个文件里通过不同入口函数区分。线程组数量的计算公式要看你在 GPU 里用的 numthreads 大小。如果声明为 numthreads(8, 8, 1)那每个线程组覆盖 8x8 像素上面代码里的除法就直接用 8 去除。如果 numthreads 是 16x16那就改成除以 16。算出来有余数一定要向上取整否则边缘那几行像素没人处理。计算着色器里的一个著名坑是 RWTexture2D 不能被采样。也就是说你不能在核函数里用 SampleLevel 去查一张已经声明为 RWTexture2D 的纹理。想查得靠 Load 方法读取特定整数坐标或者把源纹理声明成只读的 Texture2D 让它走纹理采样单元。很多人在写模糊的时候被这个卡住后面我给的 blit 代码里会说明这个细节。3.4 在 SRP 里接住相机颜色纹理聊完资源管理还要提一句怎么在通用渲染管线URP里拿到相机渲染完的图。在旧版内置渲染管线里OnRenderImage 回调会自动给你 source 和 destination 两张 RT非常简单。但在 URP/HDRP 里没有这个入口你需要自定义 ScriptableRenderPass或者使用 CommandBuffer RenderPipelineManager.endCameraRendering 的全局回调。我常用的简单方案是RenderPipelineManager.endCameraRendering (context, camera) { if (camera ! mainCamera) return; var source Shader.GetGlobalTexture(_CameraColorTexture); // 这里 source 就是场景渲染完结果做后处理再 Blit 到相机目标 };不过这个方式有个限制_CameraColorTexture并不一定会主动生成需要你在 URP 的 RenderFeature 里显式引入。更正规的做法是写一个自定义 RenderFeature在Execute里通过context.cameraColorTextureHandle拿到颜色纹理句柄。这一篇示例我就用 RenderFeature 的方式写代码量并不大但能够完整跑在 URP 上。4. 实践用 Compute Shader 实现一个后处理链4.1 目标与整体流程接下来进入实战。我选的示例是一个简化版的 Bloom 后处理链也就是发光效果。这个效果是后处理流水线的经典示例能比较全面地体现降采样、多 Pass、采样与写入、合成这几个核心操作你做完之后换参数就能得到很多变体。完整 Bloom 流程一般分四步从场景图上提取超过某一亮度阈值的像素生成“高亮图”对高亮图做降采样PingPong 下降采样到很小的尺寸在低分辨率上做高斯模糊通常横向 纵向各一遍把模糊结果加回原图再做一次色调映射输出。因为我们要演示 Compute Shader 的用法我直接把每一步都写成 Compute kernel最后再用一个合并 kernel 叠加回原图。这套流程跑下来你能看到至少 6 次 Dispatch能明显体会到资源屏障和同步对性能的影响。4.2 高亮提取与降采样第一步是提取高亮。核心思路是遍历原图的每个像素计算它的亮度值Luminance如果超过阈值就把颜色保留下来否则写成 0。亮度计算公式用标准 Rec.709 权重L 0.2126R 0.7152G 0.0722*B。#pragma kernel BrightPass [numthreads(8, 8, 1)] void BrightPass(uint3 id : SV_DispatchThreadID) { float2 uv (id.xy 0.5) * (1.0 / _ScreenSize.xy); float3 color _Source.SampleLevel(sampler_LinearClamp, uv, 0).rgb; float luma dot(color, float3(0.2126, 0.7152, 0.0722)); float brightness saturate(luma - _Threshold); float3 result color * max(brightness, 0.0); _Result[id.xy] float4(result, 1.0); }这里有一个可以优化的地方原图纹理应该声明为 Texture2D 而不是 RWTexture2D这样采样可以用硬件滤波器。上一步我们说 RWTexture2D 不能采样就是在这里体现出区别。_Source声明为 Texture2D_Result声明为 RWTexture2Dfloat4一个读一个写分工明确。降采样我选择用步骤 2 来做一个“半分辨率降采样”的简化实现把高亮图降采样到 1/4 宽高的低分辨率图每个低分辨率像素采样它对应的 2x2 区域并取平均。这样组合到后面的高斯模糊里比从头到尾做全分辨率模糊要快非常多。实际项目里甚至可以多做几级 mip每一级降一半再做模糊因为低分辨率上的模糊视觉上和高分辨率大核卷积几乎无法区分但计算量差了一个数量级。#pragma kernel Downsample [numthreads(8, 8, 1)] void Downsample(uint3 id : SV_DispatchThreadID) { float2 texel 1.0 / _ScreenSize.xy; float2 uv (id.xy 0.5) * 2.0 * texel; float4 c 0; c _Source.SampleLevel(sampler_LinearClamp, uv, 0); c _Source.SampleLevel(sampler_LinearClamp, uv float2(texel.x, 0), 0); c _Source.SampleLevel(sampler_LinearClamp, uv float2(0, texel.y), 0); c _Source.SampleLevel(sampler_LinearClamp, uv float2(texel.x, texel.y), 0); _Result[id.xy] c * 0.25; }注意这里的输出尺寸是输入宽高的一半再一半也就是各减半。Dispatch 的时候目标 RT 的宽高要先算好否则 id.xy 会超出纹理边界写入被丢弃最终画面缺一块。4.3 分离式高斯模糊多线程与共享内存的完美配合高斯的传统做法是二维卷积核比如 9x9 需要 81 次纹理采样代价很大。一个巧妙的优化是把它拆成两个一维卷积先横向模糊再纵向模糊。原因是二维高斯核是可分离的左右卷积的结果经过上下卷积等于完整的二维卷积而采样次数从 N^2 变成了 2N。以 9x9 为例81 次采样变成 18 次差距立竿见影。在 Compute Shader 里我们还能进一步偷懒双线性采样可以在很少的采样次数里模拟更大的滤波核。常见的操作是每 4 个 texel 采样一次利用纹理过滤器的线性插值得到相邻两个像素的加权平均。这样原本需要 17 次采样才能实现的 9-tap 高斯可以只用 5 次采样完成误差肉眼根本看不出来。这一步可以说是在“感知等价”的前提下把性能榨干了。代码示例横向模糊内核#pragma kernel BlurH #define BLUR_RADIUS 4 [numthreads(8, 8, 1)] void BlurH(uint3 id : SV_DispatchThreadID) { float2 texel 1.0 / _ScreenSize.xy; float2 uv (id.xy 0.5) * texel; float3 result _Source.SampleLevel(sampler_LinearClamp, uv, 0).rgb * _Weight0; [unroll] for (int i 1; i BLUR_RADIUS; i) { float2 offset float2(i * texel.x, 0); result _Source.SampleLevel(sampler_LinearClamp, uv offset, 0).rgb * _Weight[i]; result _Source.SampleLevel(sampler_LinearClamp, uv - offset, 0).rgb * _Weight[i]; } _Result[id.xy] float4(result, 1.0); }_Weight数组需要 CPU 端传入。权重可以用高斯分布公式算好也可以直接用GaussianBlur.cs之类的脚本动态生成。核心是保证所有权重加起来约等于 1否则画面会变亮或变暗。我习惯的做法是把权重放到一个 ComputeBuffer 里传进去避免在 Shader 里写死造成不想改参数还得重新编译的麻烦。如果要做大半径模糊可以利用线程组共享内存。把一条扫描线上的数据读入 groupshared 数组然后每个线程基于共享内存数据算卷积这样同一行数据只从全局内存读一次之后的所有半径都能复用。这个优化在半径大于 16 时收益显著在 3x3、5x5 这种小半径下反而可能因为同步等待抵消优势所以小半径直接用纹理采样更省事。4.4 合成与输出模糊完成后最后一步是把模糊的高光加回原图。这一步可以直接在一个合并内核里完成#pragma kernel Composite [numthreads(8, 8, 1)] void Composite(uint3 id : SV_DispatchThreadID) { float2 uv (id.xy 0.5) * (1.0 / _ScreenSize.xy); float3 source _Source.SampleLevel(sampler_LinearClamp, uv, 0).rgb; float3 blur _BlurTex.SampleLevel(sampler_LinearClamp, uv, 0).rgb; float3 result source blur * _Intensity; // 这里可以做简单的 tonemap也可以用 ACES 曲线 result result / (1.0 result); // Reinhard _Result[id.xy] float4(result, 1.0); }Reinhard 色调映射是我个人比较喜欢在移动端用的简单方案它能把任意 HDR 值压到 0~1 区间不会出现高光爆白。如果你追求电影感可以换成 ACES 拟合曲线但消耗更高移动端一般没必要。这里的_Intensity就是 Bloom 强度调参数基本上就是改这个值。要注意的是合并内核的输入_Source是原场景图它绑定的 RT 可能是 HDR 格式输出_Result是最终输出 RT 或者屏幕。如果你把结果直接写到相机目标表面要注意格式。在 URP 里我习惯于最后再做一个Blit把合并结果拷贝到 cameraColorTarget因为直接把相机目标 Texture 绑定给 RWTexture2D 并不总是被允许不同平台行为不一致。4.5 C# 端装配与调度完整的 C# 端脚本我一般这样写先分配好 RT 链再做多次 Dispatch最后释放中间 RT。public class ComputeBloomPass { private ComputeShader shader; private int kernelBright, kernelDown, kernelBlurH, kernelBlurV, kernelComposite; private RenderTexture brightTex, halfTex, blurH, blurV, compositeTex; public void Execute(RenderTexture source, RenderTexture destination) { int w source.width; int h source.height; int halfW Mathf.Max(1, w / 2); int halfH Mathf.Max(1, h / 2); // 创建 RT brightTex RenderTexture.GetTemporary(halfW, halfH, 0, RenderTextureFormat.DefaultHDR); halfTex RenderTexture.GetTemporary(halfW, halfH, 0, RenderTextureFormat.DefaultHDR); blurH RenderTexture.GetTemporary(halfW, halfH, 0, RenderTextureFormat.DefaultHDR); blurV RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.DefaultHDR); compositeTex RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.DefaultHDR); shader.SetTexture(kernelBright, _Source, source); shader.SetTexture(kernelBright, _Result, brightTex); DispatchKernel(kernelBright, halfW, halfH); shader.SetTexture(kernelDown, _Source, brightTex); shader.SetTexture(kernelDown, _Result, halfTex); DispatchKernel(kernelDown, halfW, halfH); shader.SetTexture(kernelBlurH, _Source, halfTex); shader.SetTexture(kernelBlurH, _Result, blurH); DispatchKernel(kernelBlurH, halfW, halfH); shader.SetTexture(kernelBlurV, _Source, blurH); shader.SetTexture(kernelBlurV, _Result, blurV); DispatchKernel(kernelBlurV, w, h); shader.SetTexture(kernelComposite, _Source, source); shader.SetTexture(kernelComposite, _BlurTex, blurV); shader.SetTexture(kernelComposite, _Result, compositeTex); DispatchKernel(kernelComposite, w, h); Graphics.Blit(compositeTex, destination); RenderTexture.ReleaseTemporary(brightTex); RenderTexture.ReleaseTemporary(halfTex); RenderTexture.ReleaseTemporary(blurH); RenderTexture.ReleaseTemporary(blurV); RenderTexture.ReleaseTemporary(compositeTex); } private void DispatchKernel(int kernel, int width, int height) { shader.SetVector(_ScreenSize, new Vector4(width, height, 1.0f / width, 1.0f / height)); shader.Dispatch(kernel, Mathf.CeilToInt(width / 8f), Mathf.CeilToInt(height / 8f), 1); } }这里面有个重要细节中间 RT 的宽高是半分辨率还是全分辨率直接决定后面 Kernel 里的 UV 和采样范围。我用注释把每一步都标注清楚了你在自己项目里调整时记得保持 Dispatch 的线程数和 RT 尺寸匹配。再提一个“RT 释放”的坑在上面的 Execute 里我在末尾就调用了 ReleaseTemporary看似没毛病但在 URP 的 RenderPass.Execute 上下文里这些 RT 被 GPU 渲染命令引用你立刻释放可能导致后续命令拿到未定义内容。稳妥做法是把释放操作放到下一帧的 beginCameraRendering 之前。这个细节是这个示例里最容易出 bug 的地方我后面在常见问题里还会再讲。4.6 挂在 URP 的 RenderFeature 里URP 中自定义后处理的标准方式是写一个ScriptableRendererFeature和对应的ScriptableRenderPass。下面这个精简示例展示了如何把上面的ComputeBloomPass塞进 URP 的渲染流程里。public class ComputeBloomFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public ComputeShader computeShader; public float threshold 0.8f; public float intensity 1.0f; } public Settings settings new Settings(); private ComputeBloomPass pass; public override void Create() { pass new ComputeBloomPass(); pass.shader settings.computeShader; } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { renderer.EnqueuePass(pass); } }ScriptableRenderPass的具体实现里最关键的一步是拿到源纹理和目标纹理public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { var cmd CommandBufferPool.Get(ComputeBloom); var source renderingData.cameraData.renderer.cameraColorTargetHandle; // URP 14 RenderTargetIdentifier dest BuiltinRenderTextureType.CameraTarget; // 实际要在 cmd 里执行 Dispatch 流程这里为了简洁就直接用前面的 Execute context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); }cameraColorTargetHandle在不同 URP 版本里 API 不太一样比如在 URP 12 里是cameraColorTargetURP 14 里变成了cameraColorTargetHandle。这点很容易让从旧项目迁过来的伙伴头疼建议查一下你项目的 URP 版本再去翻 API。说到底RenderFeature 的好处是它能挂到 URP Asset 的 Renderer 列表上也能通过 Inspector 调节参数让美术和策划直接调数值而不需要找你改 Shader。缺点是调试起来要亲手写很多样板代码。如果你的项目里后处理效果不多也可以用我最开始提到的RenderPipelineManager.endCameraRendering全局回调来写个简易版本但正式项目我还是建议走 RenderFeature毕竟可控性和规范度都好很多。5. 换几个玩法可直接改用的迷你后处理5.1 边缘检测描边一种“不是滤镜的滤镜”边缘检测是另一个非常适合 Compute Shader 的场景。常见的做法是用 Sobel 算子计算每个像素周围亮度的梯度梯度大说明是边缘输出到单独一张目目标纹理最后再叠加回原图。写一个 Sobel 内核并不难对当前像素周围的 3x3 邻域采样得到灰度然后用横向和纵向两个 3x3 矩阵做卷积取两个方向梯度的平方和再开方得到边缘强度。这样做和 Fragment Shader 版本的差异主要在于你在 Compute 里可以把“边缘强度”写到一个独立 RT 上然后让另一个内核读取这个 RT 再做合并。这样如果后续想要“边缘粗的”或者“边缘发光的”就不用重新跑一遍卷积只需要修改合成参数。这种中间结果复用能力在传统片元流程里很难做得这么干净。5.2 径向模糊提升画面冲击力的利器径向模糊常用在“冲击波”“速度感”“聚焦”等特效上思路是以屏幕中心为原点沿径向方向做多步采样累加。在 Compute 里实现时每个线程处理一个像素然后循环 8~16 次不断向远离中心的方向偏移采样坐标把采样结果平均。这里有个有意思的变体如果配合 Mask 纹理控制不同区域的模糊强度可以做出类似“局部景深”的效果。做法是在 GPU 里多声明一张 Mask 纹理偏移量乘以 Mask 值。这样你可以通过控制贴图的 Alpha 通道让画面四周模糊而中心清晰。性能上径向模糊是典型的“采样密集”型效果在 1080p 下做 16 次采样每个像素要跑 16 次纹理读取Fragment 方案会非常吃力Compute 方案同样要采样但它可以结合线程组共享内存把部分重复采样缓存起来所以效率会高不少。5.3 像素化与老电视效果小把戏但很讨喜最后来两个轻松点的趣味效果。像素化很简单每个线程拿到当前像素所在的块比如 8x8 像素共用一个采样坐标然后输出块中心点的颜色。虽然逻辑简单但用 Compute 写起来更自然因为你可以主动控制“块”的范围而不用反复乘除 UV。老电视效果可以拆成两部分先是扫描线每隔 N 行变暗一些再是颜色偏移把 RGB 三个通道在 x 方向上做轻微错位。这些效果如果用 Fragment Shader 也能做但配合 Compute 的灵活性你可以一次性在同一个内核里完成并且通过参数开关控制每个效果的开闭这在实际调参时特别舒服。6. 常见问题与排查技巧实录6.1 画面全黑先查 RT 格式和绑定名后处理最常见的翻车门就是全黑或者全白。全黑先看输出 RT 有没有被正确绑定核函数的参数名和 C# 里的字符串是不是完全一致——大小写和拼写错一个都不会报错但结果就是黑屏。全白大概率是你在 Shader 里写入了一个超大的值比如亮度超过 65504 这种浮点上限或者没有做色调映射直接返回了 HDR 原始值显示器无法显示所以显示成纯白。遇到这两种情况别慌先用 Debug.Log 或 Visual Studio 断点确认 Dispatch 确实执行了再说纹理绑定问题。顺序上我建议这样排查先确认 RT 尺寸和线程组数量一致再确认输出 RT 的格式不是 8 位无符号最后检查 Shader 代码是否有未初始化的变量。很多 Shader 里未赋值的局部变量在 GPU 驱动里会被赋予任意值结果就是屏幕出现随机噪点或闪烁。这个坑很难靠读代码发现建议用 RenderDoc 抓一帧直接看某个像素的着色器输入输出。6.2 线程组边界与越界访问Dispatch 数量只是“至少保证覆盖全屏”的最小数量。因为线程组大小不一定能整除纹理尺寸比如 1080 不能被 8 整除最后一行线程组的一部分线程会超出纹理范围。写的操作超出 RWTexture2D 范围时会被静默丢弃读超出范围时行为未定义可能返回黑或者随机值。处理办法是在内核开头加一段边界判断if (id.x _ScreenSize.x || id.y _ScreenSize.y) return;有人担心加了判断会影响性能其实在 GPU 里这个是 warp/波前级别的分支几乎不花钱。相反不加判断边缘出现杂色或者模糊错位调试时间才是最大成本。我现在的习惯是每个含读写的内核都统一加上这个判断一劳永逸。6.3 移动端和不同 API 的兼容性移动端的问题集中在两个方面格式和回读。格式上有些老旧的 Android GPU 不支持 R16G16B16A16_Float 的 RWTexture 写入或者写入后读取行为异常。这种情况下我建议用SystemInfo.SupportsRenderTextureFormat先检测。RGBA16F 一般没问题但保险起见可以留给配置开关。回读指用 AsyncGPUReadback 把数据从 GPU 拿回 CPU。比如你要在游戏里做“画面平均亮度统计”来驱动自动曝光可以通过 Compute Shader 先把图像不断降采样到 1x1然后再回读这一个像素。这样既拿到了数据又不会产生太大的带宽压力。不过回读终究是慢操作不要每帧做建议每隔几帧采样一次或放到协程里隔 0.1 秒做一次。6.4 性能分析瓶颈到底在哪里后处理性能分析有一个非常容易被忽略的点你在 PC 上看着一切顺畅在手机上却掉到 20 帧往往不是计算量的问题而是带宽问题。每一次从全局显存读纹理、写纹理都会消耗宝贵的带宽。所以做后处理优化的优先级一般是先降分辨率再减 Pass 数最后才考虑缩短内核代码。降分辨率是性价比最高的招。很多 Bloom 类效果完全可以在半分辨率甚至四分之一分辨率上做最后再升采样合并回原图。如果你担心“模糊后边缘太糊”可以用更高质量的升采样方案比如双三次插值在 Compute 里可以先声明一个额外的采样器来做这个事成本也不高。还有一点是尽量避免在管线中出现同步点。比如 Dispatch 之间如果存在依赖关系后一个内核要读前一个内核的输出GPU 会插入资源屏障导致流水线停顿。如果能把两个效果合并到同一个内核里做比如同时做模糊和颜色校正停顿就减少一次。当然这个要权衡代码可维护性如果只为了省一次屏障而写出一大坨不可维护的代码就得不偿失了。6.5 调试工具推荐调试 Compute Shader 后处理我强烈推荐两个工具Unity 自带的 Frame Debugger 和 RenderDoc。Frame Debugger 可以看到每个 DrawCall 和 Dispatch 的顺序以及当前绑定的 RT 内容。如果你的 Dispatch 结果没有出现在最终画面里先在 Frame Debugger 看一下它到底写没写写到了哪张 RT。RenderDoc 更强一点能查看每个线程的输入输出、纹理资源状态、甚至能看 HLSL 调试信息。在 Compute Shader 出问题又找不到原因的时候我建议你别坐在屏幕前猜赶紧装个 RenderDoc 抓一帧每一条 dispatch 的资源绑定都清清楚楚两分钟就能定位问题。最后再分享一个调试大招在 Compute Shader 里故意写一个调试 kernel把特定区域渲染成纯红色用这个标记来确认当前内核是否真的执行了。做法可以让 UV 落在某个范围内时输出红色其他位置输出黑色。如果画面里出现了红色区域就说明内核至少跑起来了问题转移到具体算法上如果完全没有红那就是绑定、调度或管线挂载的锅。这个小技巧可以在排查复杂链路时帮你快速缩小范围。根据我个人经验后处理这块最磨人的不是“不会写”而是“写了不知道跑没跑”。只要把 RT 生命周期、格式匹配和 Dispatch 参数这三个基础功练扎实绝大多数问题都能在一开始就避开。往后你可以基于这一套计算着色器后处理链继续往体积光、景深、TAA 这些高级效果延伸核心思路都是相通的。
返回列表