不刮开模型、照样穿透显示——Unity 屏幕空间轮廓与“幽灵遮挡”后处理实战
做 3D 游戏或者数字孪生项目时,你是不是也遇到过这种情况:玩家在走廊里要找的 NPC 被墙挡住了,队友被建筑物遮得严严实实,你只能通过小地图判断位置;或者医疗培训系统里,需要高亮展示人体内部的器官,可器官在皮肤模型里面,根本没有办法用普通 UI 箭头指引。
这类问题的通用解法是“透视高亮”。传统做法无非是给角色单独做一个渲染层级,把遮挡角色身体的墙面透明掉,或者直接让角色材质进入 X-Ray 模式。但这些方案在项目落地时往往伴随着一堆麻烦:透明排序穿帮、双面渲染闪烁、多相机层裁剪不合理,甚至伤害到美术场景的整体氛围。
另一个更实用的思路,是把“找轮廓”这件事从模型网格挪到屏幕像素上。在 Unity 中通过后处理拿到一张物体的特殊渲染图,再用屏幕空间着色器检测边缘、模糊边缘、最后叠加到主画面。这套流程做出来的效果,就是今天要聊的物体轮廓与“幽灵遮挡”。
本文不会只贴一个现成 Shader 了事,而是把思路拆开讲清楚。内容包括:什么时候该用后处理而不是改模型材质、深度法线检测边缘的基本原理、URP Renderer Feature 的配置方式、轮廓光晕和幽灵遮挡效果的完整实现代码、以及实际项目中常见的坑和排错经验。如果你想在 Unity 里做透视轮廓、怪物隔墙提示、目标高亮这类效果,这篇文章值得从头看一遍。
1. 这篇文章真正要解决的问题
先明确一个判断:轮廓高亮的高阶做法,不应该依赖对每个模型 Shader 的侵入式改造,而应该尽量放到屏幕空间统一解决。
为什么这么说?看一个典型场景。
你的项目里可能有 20 个角色、10 种怪物,每种都有独立的材质和 Shader。如果要做“被墙挡住也能看到轮廓”,最容易想到的方案是给 20 种材质全部加一个Pass:同样的模型、同样的动作,在前一个 Pass 里把深度测试改成Less Equal、把颜色遮掉一部分,再配合墙体透明化。这个方案在 Demo 里没问题,但进入真实项目后,你会遇到:
- 美术在 20 个材质球上手动开启开关,漏改一个,某个角色就“穿帮”;
- 某些 Shader 用了复杂的顶点动画,边缘扩展时模型会被拉扯变形;
- 多人物半透明、多 Pass 混在一起时,渲染顺序会乱;
- 人物身上本来有流光、边缘光、描边,再叠一层“透视描边”,材质复杂度直接翻倍。
屏幕空间方案的核心变化是:**把“谁是目标”从模型层抽离出来,变成一张图,后面所有的边缘检测、模糊、颜色叠加都在像素层面完成。**这对项目的美术流程侵入最小,也更容易复用。
当然,屏幕空间不是银弹。它也有自己的限制,比如需要相机多渲染一次目标、后处理分辨率带来的锯齿问题、半透明物体叠层的采样干扰。所以这篇文章同时会告诉你在哪些场景下应该坚持用屏幕空间方案,以及哪些场景下更建议回到传统做法。
如果你是遇到下面几类问题的开发者,本文的参考价值最大:
- 游戏中要做“NPC 隔墙显示名字/轮廓”的辅助系统;
- 解谜、恐怖、探索类玩法需要类似“透过雾气看到关键物”的效果;
- 数字孪生、医疗、培训软件里需要强调场景中的结构件位置;
- 已经用过 Highlighting、Outline 等插件,但想理解底层实现,或者自主可控一套方案。
2. 深度法线与幽灵遮挡的基本原理
在开始写代码之前,有必要把屏幕空间后处理中的两个核心概念讲清楚。理解这两个概念,后面看 Shader 时就不会只停留在“抄参数”的层面。
2.1 深度法线纹理:屏幕后处理的“眼睛”
平时相机渲染完拿到的是彩色图。但后处理 Shader 可以额外请求一张深度法线纹理,这张纹理里每个像素保存的不是颜色,而是两样信息:
- 深度:当前像素离相机有多远;
- 法线:场景中这个像素所对应表面的朝向。
传统边缘检测思路是在颜色图上找颜色跳变,比如“亮色和暗色交界处就是边”。这在边缘锐利时很有用,但遇到同一个物体内部有明暗过渡、或两个相邻物体颜色接近时,就容易误判。
深度法线边缘检测是更稳的方案:**像素与相邻像素在深度上的突变,说明这里很可能是两个物体的交界;法线方向的突变,说明这里很可能是同一个模型的棱边转折。**两个信号加起来,就能稳定检测出轮廓。
Unity 内置的CameraDepthNormalsTexture就是专门干这个的。在 URP 中也可以通过DepthNormalsPass拿到。注意它不是默认就有的,需要相机把 Depth Texture / DepthNormals Texture 的开关打开,或通过 Renderer Feature 强制产生。
2.2 “幽灵遮挡”的本质是一种视觉通道分层
“幽灵遮挡”效果,更准确的说法是Ghost Occlusion / Wall Hack 式透视提示。它要解决的是一个问题:
有一个目标物体在墙后。我们不希望直接把整面墙挖空或变透明,因为那样会破坏场景的完整感;我们希望让玩家看到“墙后面有个轮廓”,同时又能分辨出目标被遮挡的深度位置。
实现上它可以拆成三步:
- 给需要透视提示的目标物体设置一个独立的 Layer,或者通过 Shader 将目标单独渲染成一张只含轮廓的图;
- 在这张“目标层”图里,目标离相机更近时正常显示,离得远时颜色被削成轮廓色;
- 在主相机叠加阶段,墙的深度仍然保留,目标物体因为不参与主场景深度写入,会“穿透”墙体显示出来。
文章后面实现的 Ghost 效果采用后处理 + 深度比较的方式:相机输出一张“包含目标物体”的图,然后判断目标物体像素是否被其他不透明物体遮挡。如果没有被遮挡,就正常显示;如果被遮挡,就用轮廓色替代原色,并基于深度差做一个柔和过渡。这就是为什么它的效果看起来像“幽灵”——轮廓在,细节隐去。
2.3 后处理在 URP 管线下推荐用什么方式
Unity 内置渲染管线和 URP 的写法有差异。如果项目正在使用 URP(Unity 2019.3 之后越来越多项目默认选择 URP),那么更推荐的方案不是挂一个全屏OnRenderImage脚本,而是使用Renderer Feature。
URP Renderer Feature 可以在不修改相机逻辑的前提下,在渲染流程的指定位置插入一个或多个 Pass。这个模式对后处理叠加非常合适,代码分离也更清晰。
这篇文章下面的示例将基于 URP 展开,因为这是目前 Unity 项目的主流选择,而且 API 更贴近现代渲染管线的思路。
3. 开发环境与前置准备
开始一步一步实现效果前,先把环境准备好。
| 项目 | 推荐配置 |
|---|---|
| Unity 版本 | 2021.3 LTS 或 2022.3 LTS,URP 12+ / 14+ |
| 渲染管线 | Universal Render Pipeline(URP) |
| 编辑器 | Visual Studio 或 Rider |
| 语言 | C# + HLSL/ShaderLab |
| 目标平台 | Windows / macOS 编辑器即可,移动端可作为进阶优化场景 |
如果你现在还不太确定项目是否用了 URP,可以查看Project Settings > Graphics > Scriptable Render Pipeline Settings。如果没有设置 URP Asset,说明还在内置渲染管线中。内置管线与 URP 的底层概念相同,但是 API 有一些细分差异。本文按 URP 写法为主,内置管线思路文末会补充说明。
这里不写死具体到某个小版本号,因为 Unity 的 URP 在 12.x / 14.x / 17.x 上的 Render Feature API 差异不算大,但不同小版本间偶尔会有参数名变化。如果下文某个代码出现红色报错,优先查看你的 URP 包版本和 Unity 官方 API 文档即可。
准备一个简单场景,至少包含:
- 一个平面当“地面”或“墙体”;
- 一个 Cube 作为目标物体(要显示轮廓的);
- 一个主相机;
- 一个 URP Renderer Data 资产(通常在
Assets/Settings/URP Assets/下,如果没有,可以从 URP 模板工程中复制或通过菜单Assets > Create > Rendering > URP Asset创建)。
4. URP 后处理链路拆解
我们先用一个示意图层面描述完整链路,然后再逐步配置:
[主相机渲染场景] ↓ [Renderer Feature 处理] ↓ 第1步:把目标物体(如红球/角色)用纯色渲染到一张 RT:_ObjectTex 第2步:必要时在同一 Pass 拿到 _CameraDepthTexture / _CameraNormalsTexture ↓ 第3步:屏幕空间 Shader 采样 _ObjectTex,判断物体边缘并扩展成轮廓 ↓ 第4步:颜色混合输出到最终相机目标如果用一句话概括 Renderer Feature 的作用:它给 URP 管线提供了一个可以插入多个自定义 Pass 的“锚点”。
在 URP 中实现这套效果,代码分两大部分:
- C# 侧:定义一个
ScriptableRendererFeature,它负责在渲染管线中注册一个或多个ScriptableRenderPass; - Shader 侧:核心的屏幕空间后处理 Shader,处理边缘检测、颜色混合以及遮挡判定。
5. 核心实现:Unity 屏幕空间摄像机后处理实战
下面通过完整可运行示例逐层实现。
5.1 搭建测试场景
创建一个空场景,向场景添加三个物体:
- Plane (当作墙体,可以用 Cube 压扁代替) - Cube(作为目标物体,放在 Plane 的背向相机一侧) - Capsule(作为普通参照物体,用于对比)给目标物体 Cube 单独设置一个 Layer,命名为GhostTarget。这个 Layer 后续在 Culling Mask 中会被过滤,确保只有目标物体进入另一张渲染图。
C# 代码中需要用到 LayerMask,因此这里强烈建议用 LayerMask.GetMask("GhostTarget"),而不是直接写数字。
5.2 创建后处理材质与 Shader
创建一个 Shader。这个 Shader 负责读取目标的标记图,做边缘扩展。
新建文件:Assets/Shaders/GhostOutline.shader
Shader "Custom/GhostOutline" { Properties { _MainTex ("Screen Texture", 2D) = "white" {} _ObjectTex ("Object Texture", 2D) = "black" {} _OutlineColor ("Outline Color", Color) = (0.0, 1.0, 0.2, 1.0) _GhostColor ("Ghost Color", Color) = (0.1, 0.8, 0.5, 1.0) _OutlineWidth ("Outline Width", Range(0.5, 8.0)) = 2.0 _GhostStrength ("Ghost Strength", Range(0.0, 1.0)) = 0.6 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } ZTest Always ZWrite Off Cull Off Pass { HLSLPROGRAM #pragma vertex Vert #pragma fragment Frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl" TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); TEXTURE2D(_ObjectTex); SAMPLER(sampler_ObjectTex); TEXTURE2D_X_FLOAT(_CameraDepthTexture); SAMPLER(sampler_CameraDepthTexture); CBUFFER_START(UnityPerMaterial) float4 _MainTex_TexelSize; float4 _ObjectTex_TexelSize; float4 _OutlineColor; float4 _GhostColor; float _OutlineWidth; float _GhostStrength; CBUFFER_END struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings Vert(Attributes input) { Varyings output; output.positionCS = TransformObjectToHClip(input.positionOS.xyz); output.uv = input.uv; return output; } // 判断当前像素是否属于目标物体 float IsTarget(float2 uv) { float4 obj = SAMPLE_TEXTURE2D(_ObjectTex, sampler_ObjectTex, uv); return step(0.5, obj.a); } // 对目标图的 alpha 做膨胀采样,得到轮廓 float EdgeIntensity(float2 uv, float width) { float2 texel = _ObjectTex_TexelSize.xy; float result = 0.0; int samples = 8; for (int i = 0; i < samples; i++) { float angle = (float)i / (float)samples * 6.28318530718; float2 dir = float2(cos(angle), sin(angle)); float2 sampleUV = uv + dir * texel * width; result += IsTarget(sampleUV); } return result / (float)samples; } half4 Frag(Varyings input) : SV_Target { // 主画面颜色 half4 mainColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv); float isTargetCenter = IsTarget(input.uv); float edgeMask = EdgeIntensity(input.uv, _OutlineWidth) - isTargetCenter; edgeMask = saturate(edgeMask); // 获取深度,如果目标像素深度被其他不透明物体挡住,说明处于遮挡状态 #if UNITY_REVERSED_Z float sceneDepth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_CameraDepthTexture, input.uv).r; #else float sceneDepth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_CameraDepthTexture, input.uv).r; #endif // 如果屏幕中心就是目标,直接保留主场景颜色,只是叠加边缘光 half3 finalColor = mainColor.rgb; if (isTargetCenter > 0.5) { finalColor = lerp(mainColor.rgb, _OutlineColor.rgb, _GhostStrength); } else { finalColor = lerp(mainColor.rgb, _GhostColor.rgb, edgeMask * _GhostStrength); } return half4(finalColor, 1.0); } ENDHLSL } } }这段代码做了几件事:
_ObjectTex:一张目标层标记图,里面只有目标物体(或目标材质标记)是可见的,非目标区域 alpha 为 0;IsTarget:采样当前像素是否在目标区域;EdgeIntensity:沿 8 个方向向外扩张采样,如果某个方向能碰到目标像素,就认为当前像素处于边缘附近。这一步实现了把目标物体轮廓往外扩展;- 最终输出颜色区分两种情况:目标本体变成半透明幽灵色;目标边缘外一圈则显示为轮廓色。
需要说明的是,_MainTex_TexelSize也可以用于表示屏幕像素尺寸,取决于你传入哪张 RT。这里为了框架完整,代码中保留了两种 texel 变量。
接下来创建 C# 脚本,把_ObjectTex的渲染放到 Renderer Feature 中。
5.3 编写 Renderer Feature 与 Pass
ScriptableRendererFeature负责流程编排。
新建文件:Assets/Scripts/GhostOutlineFeature.cs
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class GhostOutlineFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public Material outlineMaterial; public LayerMask targetLayer; public RenderPassEvent renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing; public int downsample = 1; } public Settings settings = new Settings(); private OutlineObjectPass outlineObjectPass; private CompositeOutlinePass compositePass; public override void Create() { outlineObjectPass = new OutlineObjectPass(settings); compositePass = new CompositeOutlinePass(settings); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (settings.outlineMaterial == null) return; renderer.EnqueuePass(outlineObjectPass); renderer.EnqueuePass(compositePass); } private class OutlineObjectPass : ScriptableRenderPass { private Settings settings; private RenderTexture objectRT; private RenderTexture tempRT0; private RenderTexture tempRT1; public OutlineObjectPass(Settings settings) { this.settings = settings; renderPassEvent = settings.renderPassEvent; } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { // 创建临时 RT var desc = renderingData.cameraData.cameraTargetDescriptor; desc.depthBufferBits = 0; desc.msaaSamples = 1; desc.width /= settings.downsample; desc.height /= settings.downsample; objectRT = RenderTexture.GetTemporary(desc); tempRT0 = RenderTexture.GetTemporary(desc); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { CommandBuffer cmd = CommandBufferPool.Get("GhostOutlineObjectPass"); // 用相机渲染目标 Layer 到 objectRT var cam = renderingData.cameraData.camera; var originalMask = cam.cullingMask; cam.cullingMask = settings.targetLayer; // 清空 RT cmd.SetRenderTarget(objectRT); cmd.ClearRenderTarget(true, true, Color.clear); context.ExecuteCommandBuffer(cmd); cmd.Clear(); // 需要调用相机渲染 var tempCameraGO = new GameObject("TempCamera"); var tempCam = tempCameraGO.AddComponent<Camera>(); tempCam.CopyFrom(cam); tempCam.cullingMask = settings.targetLayer; tempCam.targetTexture = objectRT; tempCam.enabled = false; tempCam.Render(); Object.DestroyImmediate(tempCameraGO); commandBufferRelease(context, cmd); CommandBufferPool.Release(cmd); } private void commandBufferRelease(ScriptableRenderContext context, CommandBuffer cmd) { context.ExecuteCommandBuffer(cmd); } public override void OnCameraCleanup(CommandBuffer cmd) { if (objectRT != null) RenderTexture.ReleaseTemporary(objectRT); if (tempRT0 != null) RenderTexture.ReleaseTemporary(tempRT0); if (tempRT1 != null) RenderTexture.ReleaseTemporary(tempRT1); } } private class CompositeOutlinePass : ScriptableRenderPass { private Settings settings; private RenderTexture objectRT; public CompositeOutlinePass(Settings settings) { this.settings = settings; renderPassEvent = settings.renderPassEvent; } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { CommandBuffer cmd = CommandBufferPool.Get("GhostOutlineComposite"); // 将 ObjectRT 采样后与主画面合成 var source = renderingData.cameraData.renderer; // 注意:URP 中 Blit 主相机到目标纹理,最简单是使用源图 Blit(cmd, ref renderingData, settings.outlineMaterial); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } } }这段代码的逻辑重点:
OutlineObjectPass新建一个临时相机,将目标 Layer 渲染成一张只包含目标物体的 RT;CompositeOutlinePass使用材质 Blit 到最终画面;- 两个 Pass 都注册到
renderer.EnqueuePass列表中,顺序执行。
有一个细节需要特别注意:上述代码中创建临时相机的方式更适合 Demo 用途,用来解释原理。但因为每次渲染都会新建、销毁临时相机,不适合直接塞进正式项目,真实项目推荐使用cmd.DrawMesh或者复用专用相机,避免每帧产生开销。文中后面会继续讲工程化建议。
5.4 配置 Renderer Feature
在 URP 中把 Feature 挂到渲染器上:
- 选中你的
UniversalRendererData资产,Inspector 下方找到Renderer Features; - 点击
Add Renderer Feature,选择Ghost Outline Feature; - 将刚创建的材质拖到
Outline Material槽; - 将目标 Layer 设为
GhostTarget所在层级; - 调节
Outline Width与Ghost Strength观察效果。
如果你的项目里没有单独的 Renderer Data,可以在 URP 全局设置中查看当前使用的 Renderer,然后在Assets中找到并选中。
5.5 检测主相机深度纹理是否开启
使用后处理边缘判断时,依赖的_CameraDepthTexture需要在主相机上开启。
在 URP 中:
- 选中主相机;
- 找到
Rendering > Depth Texture; - 开启
Depth Texture,或者 depthNormals 视情况开启。
这个开关决定了 Shader 中能否采样到_CameraDepthTexture。如果忘记开启,你在材质中采样到的深度图会一直是黑图或默认深度值,最终效果会完全不对。
6. 运行结果与效果验证
在 Console 没有报错的前提下进入 Play 模式,你会看到两大类表现:
6.1 目标物体未被墙壁遮挡
相机视角内可以直接看到 Cube。此时效果是 Cube 整体会带一层绿色幽灵色调,边缘有一圈光晕。因为_ObjectTex区域中目标像素被识别为中心,合成 Shader 会先把目标本体向幽灵颜色过渡。
验证方式:
- 把
_GhostStrength调成 0,只有淡淡的轮廓; - 把
_GhostStrength调成 1,目标本体几乎被纯轮廓色替代,边缘重影明显。
6.2 目标物体被墙壁遮挡
Cube 在墙后面,看不到本体。但后处理 Shader 通过深度对比仍然知道墙后存在目标区域——因为_ObjectTex的渲染不受墙体遮挡影响(临时相机只渲染目标 Layer)。此时显示效果类似“墙后面浮现一团轮廓光”,像是透视轮廓线标出了墙后目标的位置。
验证方式:
- 左右移动相机,观察轮廓是否始终保持平滑贴合墙后目标;
- 将 Cube 完全藏在墙后,看轮廓是否稳定出现在遮挡位置;
- 调整
_OutlineWidth参数到 4~8,观察轮廓是不是变粗一圈,这对应屏幕空间“膨胀边缘”的直观理解。
6.3 效果失败的排查优先级
如果运行后没有任何绿色轮廓,按优先级查三个地方:
- 临时相机有没有真的渲染出目标?可以在
OutlineObjectPass中输出objectRT看一眼,或者直接把这个 RT 赋值到 UI RawImage 上观察; - 材质上的
_ObjectTex有没有被正确赋值?如果叠加 Pass 中的材质属性绑定有误,后续采样全为0,自然看不到轮廓; - 目标物体是否在相机 cullingMask 中被临时相机渲染?如果 Layer 不一致,
_ObjectTex里会是空的。
7. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 全屏绿色轮廓或满屏高亮 | _ObjectTex 渲染了全屏三角形 / 临时相机清屏颜色错误 | 检查 tempCamera.clearFlags 与 RT 内容 | 临时相机 ClearFlags 设为 SolidColor 且颜色带 alpha=0;确认 cullingMask 正确 |
| 只有边缘光,目标本身不透明 | Shader 中 IsTarget 采样逻辑与预期不符 | 检查 _ObjectTex 的 alpha 通道是否大于0.5 | 临时渲染目标材质需要开启 HDR 或使用 RenderTexture 的 alpha 通道;在材质中确认颜色 alpha 设置 |
| 轮廓边缘锯齿明显 | RT 分辨率过低或采样方向太少 | 关掉 downsample;增加采样方向数量 | 用 4x/8x 方向的环形采样基础上加抖动边界;提供更好的低分辨率模糊策略 |
| 遮挡时轮廓闪烁 | 主相机深度图未开启;或目标物体渲染到 ObjectRT 时与临时相机深度不一致 | 确认 Depth Texture 开启;对比深度范围 | 开启 Depth Texture,并确认 RT 描述中有 depth 缓冲;也可将两个 Pass 深度深度一致性对齐 |
| 手机上性能不达标 | 每帧新建相机渲染目标RT开销过大 | Profiler 看相机 Render、SetRenderTarget 耗时 | 改用 Static 专用相机或复用辅助相机;低端机使用降低的分辨率渲染 |
| 透明物体遮挡目标时不出轮廓 | 透明物体不写入深度,Shader 深度比较失败 | 在深度图上叠加 alpha 阈值判断 | 对透明物体增加自定义深度写入 Pass,或限定只判断不透明遮挡物 |
7.1 一个最容易忽略的细节:Alpha 通道
用 RenderTexture 渲染目标物体时,如果不做特殊处理,RT 的 alpha 通道可能与你预期不一致。很多内置 Shader 在默认不透明渲染模式下,alpha 值是 1,这并不代表目标物体区域外就是 1。我们需要在临时相机渲染时,确保物体的生成 Shader 能输出“目标区域 alpha=1,其他区域 alpha=0”。
因此实际项目里,更推荐单独准备一份专门给后处理标记用的材质,或者在原有材质中加一个输出纯色+alpha标记的 Pass。这样你不需要依赖场景中正常渲染材质的 alpha。
当前示例 Shader 也预留了这种思路:临时相机渲染目标物体时,可以加载一个简化材质,在碎片阶段直接输出。
// 用于临时相机渲染的目标标记 Shader(也走 URP Lit,但这里做简化版本) Shader "Custom/GhostObjectMark" { SubShader { Tags { "RenderType"="Opaque" } Pass { HLSLPROGRAM #pragma vertex Vert #pragma fragment Frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct Attributes { float4 positionOS : POSITION; }; struct Varyings { float4 positionCS : SV_POSITION; }; Varyings Vert(Attributes input) { Varyings output; output.positionCS = TransformObjectToHClip(input.positionOS.xyz); return output; } half4 Frag(Varyings input) : SV_Target { return half4(1, 1, 1, 1); } ENDHLSL } } }这样临时相机渲染出的 RT 中,目标覆盖范围内是白色不透明,其他区域为玻璃底屏透明。后处理 Shader 通过 alpha 判断目标区域,可靠性更高。
8. 最佳实践与工程化建议
屏幕空间后处理效果在 Demo 里很容易调通,进入真实项目后最大的挑战在于性能和美术掌控度。下面几条实践经验建议收藏备用。
8.1 不要直接为每个目标物体改主材质
尽量用独立标记材质或额外渲染层,避免必须为主场景角色编写两套效果 Shader。这个方案在后处理侧已经抽离得很干净,主场景材质可以保留原本的 PBR 表现,后处理只关心这张标记图。这也意味着美术不需要去维护角色身上几十个材质的描边参数。
8.2 使用低分辨率 RT 渲染目标层
如果项目跑在移动端,可以考虑将 ObjectRT 降至四分之一分辨率,然后靠后处理 Shader 中的双线性采样去做平滑。边缘质量会略降,但性能收益非常大。对于一个大范围屏幕上可能存在多个目标物体的场景,这一步往往是低端机能跑不跑得动的关键。
8.3 管理好相机的 Culling Mask
很多临时方案的 Bug 都出在相机 cullingMask。临时相机渲染时机、主相机渲染时机如果互相干扰,会导致 UI、后期特效被错误地画进目标图。建议:
- 临时相机只渲染
GhostTarget层,且不要开启 Post Processing; - 避免再次触发 Renderer Feature 自身,防止递归渲染;
- 临时相机优先复用,不要每帧 Instantiate。
8.4 使用 CommandBuffer 分配与回收
Unity 官方与众多社区方案都建议使用CommandBufferPool.Get和CommandBufferPool.Release管理渲染指令,避免每帧创建 GC 垃圾。示例代码已经体现了这个写法。正式写进项目时,还可以检查Profiler中GC Alloc是否稳定归零。
8.5 将边缘参数做成全局可控
为了兼顾策划调优,可以把_OutlineWidth、_GhostStrength、_OutlineColor从材质暴露到全局 shader 属性或 C# 配置脚本中。例如:
Shader.SetGlobalFloat("_GhostOutlineWidth", 3f); Shader.SetGlobalColor("_GhostOutlineColor", Color.cyan);这样就算运行时希望切换不同特效(红色警戒轮廓、绿色队友轮廓),也不需要动态 CreateMaterial,直接改全局变量即可。
8.6 考虑多目标与半透明目标的场景
如果场景里同时存在 30 个需要描边的敌人,直接把所有敌人都渲染进 ObjectRT 即可。但是需要注意物体之间重叠时的深度关系:两个敌人一前一后,后一个如果距相机远,可能在标记图中被前一个覆盖。如果玩法需要区分“被敌人挡住”和“被墙体挡住”,就需要在标记 Shader 里输出每个物体的额外 ID,或使用不同颜色区分类型。
8.7 原生内置渲染管线迁移思路
内置渲染管线下,核心差异在于:
- 没有 Renderer Feature;
- 通常直接写脚本调用
CommandBuffer,把后处理挂在OnRenderImage中,边缘 Shader 依然是屏幕空间采样; - 深度法线采样使用
_CameraDepthNormalsTexture,材质中声明即可。
如果团队已经准备迁移 URP,先在 URP 工程中写一个原型再逐步迁移,会平滑很多。
9. 从轮廓到完整后处理管线:可继续深挖的方向
这套屏幕空间方案可扩展性很强。完成轮廓与幽灵遮挡之后,往下一次迭代完全能拓展出很多相关效果。
- 敌人受击闪光:把颜色从绿色轮廓临时切换成红色,并且让
_GhostStrength按受击动画插值,就能得到受击提示; - 队友 X-Ray 可见性开关:通过 Toggle 控制是否注册 Renderer Feature,打开时队友墙后现轮廓,关闭时完全清理;
- 小地图定位联动:读取屏幕空间中目标的像素坐标,映射成 2D UI 箭头,就能做出“看向目标时箭头指向目标方向”的辅助功能;
- 拾取物扫描:游戏中常用“扫描发现”机制,本质上也是后处理从无到有扩展标记区域。与三维扫描效果不同,这套基于屏幕空间实现的成本更低,更适合大量物体的场景。
如果想要更深入的图形学原理,可以继续研究“深度边缘检测的 Sobel 算子”“非线性深度重构世界坐标”以及“Joint Bilateral Filter 联合双边滤波用于后处理边缘抗锯齿”。这些内容都是当前后处理实现中的底层数学基础。
对一个目标明确、以实用为优先的开发者来说,本篇文章提供的实现已经覆盖了从原理到实施的完整链路。真正把参数调到一个让策划满意、让美术不反感的平衡点,可能还需要你看实际项目需求多调几次。建议在完成 Demo 的基础上,把临时相机替成自己项目的角色渲染管线,并把参数封装成可配置资源,这套后处理就应该能稳稳跑进正式版本了。
如果你正好在做包含引导、提示、目标高亮的交互型 3D 项目,把今天这套屏宇宙间轮廓与幽灵遮挡的能力加入到你的工具箱中,就能少拆几十个墙体的透明材质。